diff --git a/apis/supervisor/config/v1alpha1/types_federationdomain.go.tmpl b/apis/supervisor/config/v1alpha1/types_federationdomain.go.tmpl index 27de4401..95f7da28 100644 --- a/apis/supervisor/config/v1alpha1/types_federationdomain.go.tmpl +++ b/apis/supervisor/config/v1alpha1/types_federationdomain.go.tmpl @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl b/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl index 48f5de37..61106fdb 100644 --- a/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl +++ b/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/cmd/pinniped/cmd/login_oidc.go b/cmd/pinniped/cmd/login_oidc.go index 69b8951c..ac76cb4f 100644 --- a/cmd/pinniped/cmd/login_oidc.go +++ b/cmd/pinniped/cmd/login_oidc.go @@ -164,7 +164,7 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin // Initialize the login handler. opts := []oidcclient.Option{ oidcclient.WithContext(cmd.Context()), - oidcclient.WithLogger(plog.Logr()), //nolint:staticcheck // old code with lots of log statements + oidcclient.WithLogger(plog.Logr()), //nolint:staticcheck // old code with lots of log statements oidcclient.WithScopes(flags.scopes), oidcclient.WithSessionCache(sessionCache), } diff --git a/deploy/supervisor/config.supervisor.pinniped.dev_federationdomains.yaml b/deploy/supervisor/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/deploy/supervisor/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/deploy/supervisor/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.21/README.adoc b/generated/1.21/README.adoc index d34a634e..a27e6d3c 100644 --- a/generated/1.21/README.adoc +++ b/generated/1.21/README.adoc @@ -652,6 +652,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -689,7 +720,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -706,25 +740,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -746,6 +767,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.21/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.21/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.21/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.21/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.21/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.21/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.21/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.21/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.21/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.21/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..7ffd8875 100644 --- a/generated/1.21/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.21/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + type FooStatus struct{ // Represents the observations of a foo's + current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.22/README.adoc b/generated/1.22/README.adoc index 8d17cd49..09c4ca18 100644 --- a/generated/1.22/README.adoc +++ b/generated/1.22/README.adoc @@ -652,6 +652,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -689,7 +720,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -706,25 +740,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -746,6 +767,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.22/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.22/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.22/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.22/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.22/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.22/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.22/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.22/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.22/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.22/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..7ffd8875 100644 --- a/generated/1.22/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.22/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + type FooStatus struct{ // Represents the observations of a foo's + current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.23/README.adoc b/generated/1.23/README.adoc index c6117ae2..255975ed 100644 --- a/generated/1.23/README.adoc +++ b/generated/1.23/README.adoc @@ -652,6 +652,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.23/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -689,7 +720,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -706,25 +740,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.23/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.23/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -746,6 +767,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.23/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.23/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.23/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.23/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.23/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.23/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.23/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.23/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.23/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.23/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.23/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.23/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.24/README.adoc b/generated/1.24/README.adoc index b53ace67..7d524038 100644 --- a/generated/1.24/README.adoc +++ b/generated/1.24/README.adoc @@ -652,6 +652,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -689,7 +720,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -706,25 +740,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -746,6 +767,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.24/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.24/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.24/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.24/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.24/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.24/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.24/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.24/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.24/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.24/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.24/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.24/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.25/README.adoc b/generated/1.25/README.adoc index 04d0ab66..916d768b 100644 --- a/generated/1.25/README.adoc +++ b/generated/1.25/README.adoc @@ -650,6 +650,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -687,7 +718,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -704,25 +738,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -744,6 +765,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.25/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.25/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.25/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.25/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.25/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.25/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.25/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.25/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.25/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.25/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.25/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.25/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.25/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.25/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.25/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.25/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.26/README.adoc b/generated/1.26/README.adoc index 69bf458b..47b96cc6 100644 --- a/generated/1.26/README.adoc +++ b/generated/1.26/README.adoc @@ -650,6 +650,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -687,7 +718,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -704,25 +738,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -744,6 +765,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.26/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.26/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.26/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.26/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.26/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.26/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.26/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.26/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.26/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.26/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.26/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.26/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.26/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.26/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.26/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.26/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.27/README.adoc b/generated/1.27/README.adoc index 41ef8ab7..f34bc05b 100644 --- a/generated/1.27/README.adoc +++ b/generated/1.27/README.adoc @@ -650,6 +650,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -687,7 +718,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -704,25 +738,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -744,6 +765,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.27/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.27/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.27/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.27/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.27/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.27/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.27/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.27/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.27/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.27/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.27/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.27/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.27/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.27/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.27/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.27/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/1.28/README.adoc b/generated/1.28/README.adoc index 08db55da..10a5f3a2 100644 --- a/generated/1.28/README.adoc +++ b/generated/1.28/README.adoc @@ -650,6 +650,37 @@ FederationDomain describes the configuration of an OIDC provider. |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainidentityprovider"] +==== FederationDomainIdentityProvider + +FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainspec[$$FederationDomainSpec$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`displayName`* __string__ | DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a disruptive change for those users. +| *`objectRef`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#typedlocalobjectreference-v1-core[$$TypedLocalObjectReference$$]__ | ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. If the reference cannot be resolved then the identity provider will not be made available. Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, ActiveDirectoryIdentityProvider. +| *`transforms`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$]__ | Transforms is an optional way to specify transformations to be applied during user authentication and session refresh. +|=== + + + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainphase"] +==== FederationDomainPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] +**** + [id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainsecrets"] @@ -687,7 +718,10 @@ FederationDomainSpec is a struct that describes an OIDC Provider. | Field | Description | *`issuer`* __string__ | Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for the iss claim in issued JWTs. This field will also be used as the base URL for any endpoints used by the OIDC Provider (e.g., if your issuer is https://example.com/foo, then your authorization endpoint will look like https://example.com/foo/some/path/to/auth/endpoint). See https://openid.net/specs/openid-connect-discovery-1_0.html#rfc.section.3 for more information. -| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS configures how this FederationDomain is served over Transport Layer Security (TLS). +| *`tls`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintlsspec[$$FederationDomainTLSSpec$$]__ | TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. +| *`identityProviders`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] array__ | IdentityProviders is the list of identity providers available for use by this FederationDomain. + An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to extract a normalized user identity. Normalized user identities include a username and a list of group names. In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid accidental conflicts when multiple identity providers have different users with the same username (e.g. "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could disallow the authentication unless the user belongs to a specific group in the identity provider. + For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which exist in the same namespace, but also to reject all authentication requests when there is more than one identity provider currently defined. In this backwards compatibility mode, the name of the identity provider resource (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead explicitly list the identity provider using this IdentityProviders field. |=== @@ -704,25 +738,12 @@ FederationDomainStatus is a struct that describes the actual state of an OIDC Pr [cols="25a,75a", options="header"] |=== | Field | Description -| *`status`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainstatuscondition[$$FederationDomainStatusCondition$$]__ | Status holds an enum that describes the state of this OIDC Provider. Note that this Status can represent success or failure. -| *`message`* __string__ | Message provides human-readable details about the Status. -| *`lastUpdateTime`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#time-v1-meta[$$Time$$]__ | LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get around some undesirable behavior with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainphase[$$FederationDomainPhase$$]__ | Phase summarizes the overall status of the FederationDomain. +| *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#condition-v1-meta[$$Condition$$] array__ | Conditions represent the observations of an FederationDomain's current state. | *`secrets`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainsecrets[$$FederationDomainSecrets$$]__ | Secrets contains information about this OIDC Provider's secrets. |=== -[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainstatuscondition"] -==== FederationDomainStatusCondition (string) - - - -.Appears In: -**** -- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainstatus[$$FederationDomainStatus$$] -**** - - - [id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintlsspec"] ==== FederationDomainTLSSpec @@ -744,6 +765,106 @@ FederationDomainTLSSpec is a struct that describes the TLS configuration for an |=== +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransforms"] +==== FederationDomainTransforms + +FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomainidentityprovider[$$FederationDomainIdentityProvider$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`constants`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant[$$FederationDomainTransformsConstant$$] array__ | Constants defines constant variables and their values which will be made available to the transform expressions. +| *`expressions`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression[$$FederationDomainTransformsExpression$$] array__ | Expressions are an optional list of transforms and policies to be executed in the order given during every authentication attempt, including during every session refresh. Each is a CEL expression. It may use the basic CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + The username and groups extracted from the identity provider, and the constants defined in this CR, are available as variables in all expressions. The username is provided via a variable called `username` and the list of group names is provided via a variable called `groups` (which may be an empty list). Each user-provided constants is provided via a variable named `strConst.varName` for string constants and `strListConst.varName` for string list constants. + The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated and the authentication attempt is rejected. Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the username or group names. Each username/v1 transform must return the new username (a string), which can be the same as the old username. Transformations of type username/v1 do not return group names, and therefore cannot change the group names. Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old groups list. Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. After each expression, the new (potentially changed) username or groups get passed to the following expression. + Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username and group names have been decided for that authentication attempt. +| *`examples`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] array__ | Examples can optionally be used to ensure that the sequence of transformation expressions are working as expected. Examples define sample input identities which are then run through the expression list, and the results are compared to the expected results. If any example in this list fails, then this identity provider will not be available for use within this FederationDomain, and the error(s) will be added to the FederationDomain status. This can be used to help guard against programming mistakes in the expressions, and also act as living documentation for other administrators to better understand the expressions. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsconstant"] +==== FederationDomainTransformsConstant + +FederationDomainTransformsConstant defines a constant variable and its value which will be made available to the transform expressions. This is a union type, and Type is the discriminator field. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`name`* __string__ | Name determines the name of the constant. It must be a valid identifier name. +| *`type`* __string__ | Type determines the type of the constant, and indicates which other field should be non-empty. +| *`stringValue`* __string__ | StringValue should hold the value when Type is "string", and is otherwise ignored. +| *`stringListValue`* __string array__ | StringListValue should hold the value when Type is "stringList", and is otherwise ignored. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexample"] +==== FederationDomainTransformsExample + +FederationDomainTransformsExample defines a transform example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the input username. +| *`groups`* __string array__ | Groups is the input list of group names. +| *`expects`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects[$$FederationDomainTransformsExampleExpects$$]__ | Expects is the expected output of the entire sequence of transforms when they are run against the input Username and Groups. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexampleexpects"] +==== FederationDomainTransformsExampleExpects + +FederationDomainTransformsExampleExpects defines the expected result for a transforms example. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexample[$$FederationDomainTransformsExample$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`username`* __string__ | Username is the expected username after the transformations have been applied. +| *`groups`* __string array__ | Groups is the expected list of group names after the transformations have been applied. +| *`rejected`* __boolean__ | Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression after the transformations have been applied. True means that it is expected that the authentication would be rejected. The default value of false means that it is expected that the authentication would not be rejected by any policy expression. +| *`message`* __string__ | Message is the expected error message of the transforms. When Rejected is true, then Message is the expected message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, then Message will be treated as the default error message for authentication attempts which are rejected by a policy. When Rejected is false, then Message is the expected error message for some other non-policy transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. +|=== + + +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransformsexpression"] +==== FederationDomainTransformsExpression + +FederationDomainTransformsExpression defines a transform expression. + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-federationdomaintransforms[$$FederationDomainTransforms$$] +**** + +[cols="25a,75a", options="header"] +|=== +| Field | Description +| *`type`* __string__ | Type determines the type of the expression. It must be one of the supported types. +| *`expression`* __string__ | Expression is a CEL expression that will be evaluated based on the Type during an authentication. +| *`message`* __string__ | Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects an authentication attempt. When empty, a default message will be used. +|=== + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-supervisor-config-v1alpha1-granttype"] ==== GrantType (string) diff --git a/generated/1.28/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/1.28/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/1.28/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/1.28/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/1.28/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.28/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/1.28/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.28/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/1.28/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/1.28/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 77defc47..018a3023 100644 --- a/generated/1.28/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/1.28/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -41,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -102,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -118,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -152,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/1.28/crds/config.supervisor.pinniped.dev_federationdomains.yaml b/generated/1.28/crds/config.supervisor.pinniped.dev_federationdomains.yaml index 71f7370d..21256960 100644 --- a/generated/1.28/crds/config.supervisor.pinniped.dev_federationdomains.yaml +++ b/generated/1.28/crds/config.supervisor.pinniped.dev_federationdomains.yaml @@ -21,7 +21,7 @@ spec: - jsonPath: .spec.issuer name: Issuer type: string - - jsonPath: .status.status + - jsonPath: .status.phase name: Status type: string - jsonPath: .metadata.creationTimestamp @@ -47,6 +47,263 @@ spec: spec: description: Spec of the OIDC provider. properties: + identityProviders: + description: "IdentityProviders is the list of identity providers + available for use by this FederationDomain. \n An identity provider + CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes + how to connect to a server, how to talk in a specific protocol for + authentication, and how to use the schema of that server/protocol + to extract a normalized user identity. Normalized user identities + include a username and a list of group names. In contrast, IdentityProviders + describes how to use that normalized identity in those Kubernetes + clusters which belong to this FederationDomain. Each entry in IdentityProviders + can be configured with arbitrary transformations on that normalized + identity. For example, a transformation can add a prefix to all + usernames to help avoid accidental conflicts when multiple identity + providers have different users with the same username (e.g. \"idp1:ryan\" + versus \"idp2:ryan\"). Each entry in IdentityProviders can also + implement arbitrary authentication rejection policies. Even though + a user was able to authenticate with the identity provider, a policy + can disallow the authentication to the Kubernetes clusters that + belong to this FederationDomain. For example, a policy could disallow + the authentication unless the user belongs to a specific group in + the identity provider. \n For backwards compatibility with versions + of Pinniped which predate support for multiple identity providers, + an empty IdentityProviders list will cause the FederationDomain + to use all available identity providers which exist in the same + namespace, but also to reject all authentication requests when there + is more than one identity provider currently defined. In this backwards + compatibility mode, the name of the identity provider resource (e.g. + the Name of an OIDCIdentityProvider resource) will be used as the + name of the identity provider in this FederationDomain. This mode + is provided to make upgrading from older versions easier. However, + instead of relying on this backwards compatibility mode, please + consider this mode to be deprecated and please instead explicitly + list the identity provider using this IdentityProviders field." + items: + description: FederationDomainIdentityProvider describes how an identity + provider is made available in this FederationDomain. + properties: + displayName: + description: DisplayName is the name of this identity provider + as it will appear to clients. This name ends up in the kubeconfig + of end users, so changing the name of an identity provider + that is in use by end users will be a disruptive change for + those users. + minLength: 1 + type: string + objectRef: + description: ObjectRef is a reference to a Pinniped identity + provider resource. A valid reference is required. If the reference + cannot be resolved then the identity provider will not be + made available. Must refer to a resource of one of the Pinniped + identity provider types, e.g. OIDCIdentityProvider, LDAPIdentityProvider, + ActiveDirectoryIdentityProvider. + properties: + apiGroup: + description: APIGroup is the group for the resource being + referenced. If APIGroup is not specified, the specified + Kind must be in the core API group. For any other third-party + types, APIGroup is required. + type: string + kind: + description: Kind is the type of resource being referenced + type: string + name: + description: Name is the name of resource being referenced + type: string + required: + - kind + - name + type: object + transforms: + description: Transforms is an optional way to specify transformations + to be applied during user authentication and session refresh. + properties: + constants: + description: Constants defines constant variables and their + values which will be made available to the transform expressions. + items: + description: FederationDomainTransformsConstant defines + a constant variable and its value which will be made + available to the transform expressions. This is a union + type, and Type is the discriminator field. + properties: + name: + description: Name determines the name of the constant. + It must be a valid identifier name. + maxLength: 64 + minLength: 1 + pattern: ^[a-zA-Z][_a-zA-Z0-9]*$ + type: string + stringListValue: + description: StringListValue should hold the value + when Type is "stringList", and is otherwise ignored. + items: + type: string + type: array + stringValue: + description: StringValue should hold the value when + Type is "string", and is otherwise ignored. + type: string + type: + description: Type determines the type of the constant, + and indicates which other field should be non-empty. + enum: + - string + - stringList + type: string + required: + - name + - type + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + examples: + description: Examples can optionally be used to ensure that + the sequence of transformation expressions are working + as expected. Examples define sample input identities which + are then run through the expression list, and the results + are compared to the expected results. If any example in + this list fails, then this identity provider will not + be available for use within this FederationDomain, and + the error(s) will be added to the FederationDomain status. + This can be used to help guard against programming mistakes + in the expressions, and also act as living documentation + for other administrators to better understand the expressions. + items: + description: FederationDomainTransformsExample defines + a transform example. + properties: + expects: + description: Expects is the expected output of the + entire sequence of transforms when they are run + against the input Username and Groups. + properties: + groups: + description: Groups is the expected list of group + names after the transformations have been applied. + items: + type: string + type: array + message: + description: Message is the expected error message + of the transforms. When Rejected is true, then + Message is the expected message for the policy + which rejected the authentication attempt. When + Rejected is true and Message is blank, then + Message will be treated as the default error + message for authentication attempts which are + rejected by a policy. When Rejected is false, + then Message is the expected error message for + some other non-policy transformation error, + such as a runtime error. When Rejected is false, + there is no default expected Message. + type: string + rejected: + description: Rejected is a boolean that indicates + whether authentication is expected to be rejected + by a policy expression after the transformations + have been applied. True means that it is expected + that the authentication would be rejected. The + default value of false means that it is expected + that the authentication would not be rejected + by any policy expression. + type: boolean + username: + description: Username is the expected username + after the transformations have been applied. + type: string + type: object + groups: + description: Groups is the input list of group names. + items: + type: string + type: array + username: + description: Username is the input username. + minLength: 1 + type: string + required: + - expects + - username + type: object + type: array + expressions: + description: "Expressions are an optional list of transforms + and policies to be executed in the order given during + every authentication attempt, including during every session + refresh. Each is a CEL expression. It may use the basic + CEL language as defined in https://github.com/google/cel-spec/blob/master/doc/langdef.md + plus the CEL string extensions defined in https://github.com/google/cel-go/tree/master/ext#strings. + \n The username and groups extracted from the identity + provider, and the constants defined in this CR, are available + as variables in all expressions. The username is provided + via a variable called `username` and the list of group + names is provided via a variable called `groups` (which + may be an empty list). Each user-provided constants is + provided via a variable named `strConst.varName` for string + constants and `strListConst.varName` for string list constants. + \n The only allowed types for expressions are currently + policy/v1, username/v1, and groups/v1. Each policy/v1 + must return a boolean, and when it returns false, no more + expressions from the list are evaluated and the authentication + attempt is rejected. Transformations of type policy/v1 + do not return usernames or group names, and therefore + cannot change the username or group names. Each username/v1 + transform must return the new username (a string), which + can be the same as the old username. Transformations of + type username/v1 do not return group names, and therefore + cannot change the group names. Each groups/v1 transform + must return the new groups list (list of strings), which + can be the same as the old groups list. Transformations + of type groups/v1 do not return usernames, and therefore + cannot change the usernames. After each expression, the + new (potentially changed) username or groups get passed + to the following expression. \n Any compilation or static + type-checking failure of any expression will cause an + error status on the FederationDomain. During an authentication + attempt, any unexpected runtime evaluation errors (e.g. + division by zero) cause the authentication attempt to + fail. When all expressions evaluate successfully, then + the (potentially changed) username and group names have + been decided for that authentication attempt." + items: + description: FederationDomainTransformsExpression defines + a transform expression. + properties: + expression: + description: Expression is a CEL expression that will + be evaluated based on the Type during an authentication. + minLength: 1 + type: string + message: + description: Message is only used when Type is policy/v1. + It defines an error message to be used when the + policy rejects an authentication attempt. When empty, + a default message will be used. + type: string + type: + description: Type determines the type of the expression. + It must be one of the supported types. + enum: + - policy/v1 + - username/v1 + - groups/v1 + type: string + required: + - expression + - type + type: object + type: array + type: object + required: + - displayName + - objectRef + type: object + type: array issuer: description: "Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the identifier that it will use for @@ -59,8 +316,8 @@ spec: minLength: 1 type: string tls: - description: TLS configures how this FederationDomain is served over - Transport Layer Security (TLS). + description: TLS specifies a secret which will contain Transport Layer + Security (TLS) configuration for the FederationDomain. properties: secretName: description: "SecretName is an optional name of a Secret in the @@ -91,14 +348,86 @@ spec: status: description: Status of the OIDC provider. properties: - lastUpdateTime: - description: LastUpdateTime holds the time at which the Status was - last updated. It is a pointer to get around some undesirable behavior - with respect to the empty metav1.Time value (see https://github.com/kubernetes/kubernetes/issues/86811). - format: date-time - type: string - message: - description: Message provides human-readable details about the Status. + conditions: + description: Conditions represent the observations of an FederationDomain's + current state. + items: + description: "Condition contains details for one aspect of the current + state of this API Resource. --- This struct is intended for direct + use as an array at the field path .status.conditions. For example, + \n type FooStatus struct{ // Represents the observations of a + foo's current state. // Known .status.conditions.type are: \"Available\", + \"Progressing\", and \"Degraded\" // +patchMergeKey=type // +patchStrategy=merge + // +listType=map // +listMapKey=type Conditions []metav1.Condition + `json:\"conditions,omitempty\" patchStrategy:\"merge\" patchMergeKey:\"type\" + protobuf:\"bytes,1,rep,name=conditions\"` \n // other fields }" + properties: + lastTransitionTime: + description: lastTransitionTime is the last time the condition + transitioned from one status to another. This should be when + the underlying condition changed. If that is not known, then + using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: message is a human readable message indicating + details about the transition. This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: observedGeneration represents the .metadata.generation + that the condition was set based upon. For instance, if .metadata.generation + is currently 12, but the .status.conditions[x].observedGeneration + is 9, the condition is out of date with respect to the current + state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: reason contains a programmatic identifier indicating + the reason for the condition's last transition. Producers + of specific condition types may define expected values and + meanings for this field, and whether the values are considered + a guaranteed API. The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + --- Many .condition.type values are consistent across resources + like Available, but because arbitrary conditions can be useful + (see .node.status.conditions), the ability to deconflict is + important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt) + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the FederationDomain. + enum: + - Pending + - Ready + - Error type: string secrets: description: Secrets contains information about this OIDC Provider's @@ -145,15 +474,6 @@ spec: type: string type: object type: object - status: - description: Status holds an enum that describes the state of this - OIDC Provider. Note that this Status can represent success or failure. - enum: - - Success - - Duplicate - - Invalid - - SameIssuerHostMustUseSameSecret - type: string type: object required: - spec diff --git a/generated/latest/apis/concierge/authentication/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/concierge/authentication/v1alpha1/zz_generated.deepcopy.go index a0e57b94..2b36eaa8 100644 --- a/generated/latest/apis/concierge/authentication/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/authentication/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/config/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/concierge/config/v1alpha1/zz_generated.deepcopy.go index 4a337398..d4a01ba4 100644 --- a/generated/latest/apis/concierge/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/config/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.conversion.go b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.conversion.go index 1786f06a..700a5598 100644 --- a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.conversion.go +++ b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.conversion.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.deepcopy.go index 533b24ce..2feed25b 100644 --- a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.defaults.go b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.defaults.go index 8d370915..abdbfa5f 100644 --- a/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.defaults.go +++ b/generated/latest/apis/concierge/identity/v1alpha1/zz_generated.defaults.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/identity/zz_generated.deepcopy.go b/generated/latest/apis/concierge/identity/zz_generated.deepcopy.go index e7ca4d6f..840da45e 100644 --- a/generated/latest/apis/concierge/identity/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/identity/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.conversion.go b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.conversion.go index 0461660e..bc409d26 100644 --- a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.conversion.go +++ b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.conversion.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.deepcopy.go index fb5263c8..0d64c87a 100644 --- a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.defaults.go b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.defaults.go index 8d370915..abdbfa5f 100644 --- a/generated/latest/apis/concierge/login/v1alpha1/zz_generated.defaults.go +++ b/generated/latest/apis/concierge/login/v1alpha1/zz_generated.defaults.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/concierge/login/zz_generated.deepcopy.go b/generated/latest/apis/concierge/login/zz_generated.deepcopy.go index ecf3a3c4..f332880e 100644 --- a/generated/latest/apis/concierge/login/zz_generated.deepcopy.go +++ b/generated/latest/apis/concierge/login/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.conversion.go b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.conversion.go index 692c322d..3c9326d5 100644 --- a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.conversion.go +++ b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.conversion.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.deepcopy.go index 0280f8ca..cd9c904e 100644 --- a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.defaults.go b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.defaults.go index 8d370915..abdbfa5f 100644 --- a/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.defaults.go +++ b/generated/latest/apis/supervisor/clientsecret/v1alpha1/zz_generated.defaults.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/supervisor/clientsecret/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/clientsecret/zz_generated.deepcopy.go index 49a60f3d..4153df60 100644 --- a/generated/latest/apis/supervisor/clientsecret/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/clientsecret/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/apis/supervisor/config/v1alpha1/types_federationdomain.go b/generated/latest/apis/supervisor/config/v1alpha1/types_federationdomain.go index 27de4401..95f7da28 100644 --- a/generated/latest/apis/supervisor/config/v1alpha1/types_federationdomain.go +++ b/generated/latest/apis/supervisor/config/v1alpha1/types_federationdomain.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 @@ -8,14 +8,17 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// +kubebuilder:validation:Enum=Success;Duplicate;Invalid;SameIssuerHostMustUseSameSecret -type FederationDomainStatusCondition string +type FederationDomainPhase string const ( - SuccessFederationDomainStatusCondition = FederationDomainStatusCondition("Success") - DuplicateFederationDomainStatusCondition = FederationDomainStatusCondition("Duplicate") - SameIssuerHostMustUseSameSecretFederationDomainStatusCondition = FederationDomainStatusCondition("SameIssuerHostMustUseSameSecret") - InvalidFederationDomainStatusCondition = FederationDomainStatusCondition("Invalid") + // FederationDomainPhasePending is the default phase for newly-created FederationDomain resources. + FederationDomainPhasePending FederationDomainPhase = "Pending" + + // FederationDomainPhaseReady is the phase for an FederationDomain resource in a healthy state. + FederationDomainPhaseReady FederationDomainPhase = "Ready" + + // FederationDomainPhaseError is the phase for an FederationDomain in an unhealthy state. + FederationDomainPhaseError FederationDomainPhase = "Error" ) // FederationDomainTLSSpec is a struct that describes the TLS configuration for an OIDC Provider. @@ -42,6 +45,157 @@ type FederationDomainTLSSpec struct { SecretName string `json:"secretName,omitempty"` } +// FederationDomainTransformsConstant defines a constant variable and its value which will be made available to +// the transform expressions. This is a union type, and Type is the discriminator field. +type FederationDomainTransformsConstant struct { + // Name determines the name of the constant. It must be a valid identifier name. + // +kubebuilder:validation:Pattern=`^[a-zA-Z][_a-zA-Z0-9]*$` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=64 + Name string `json:"name"` + + // Type determines the type of the constant, and indicates which other field should be non-empty. + // +kubebuilder:validation:Enum=string;stringList + Type string `json:"type"` + + // StringValue should hold the value when Type is "string", and is otherwise ignored. + // +optional + StringValue string `json:"stringValue,omitempty"` + + // StringListValue should hold the value when Type is "stringList", and is otherwise ignored. + // +optional + StringListValue []string `json:"stringListValue,omitempty"` +} + +// FederationDomainTransformsExpression defines a transform expression. +type FederationDomainTransformsExpression struct { + // Type determines the type of the expression. It must be one of the supported types. + // +kubebuilder:validation:Enum=policy/v1;username/v1;groups/v1 + Type string `json:"type"` + + // Expression is a CEL expression that will be evaluated based on the Type during an authentication. + // +kubebuilder:validation:MinLength=1 + Expression string `json:"expression"` + + // Message is only used when Type is policy/v1. It defines an error message to be used when the policy rejects + // an authentication attempt. When empty, a default message will be used. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransformsExample defines a transform example. +type FederationDomainTransformsExample struct { + // Username is the input username. + // +kubebuilder:validation:MinLength=1 + Username string `json:"username"` + + // Groups is the input list of group names. + // +optional + Groups []string `json:"groups,omitempty"` + + // Expects is the expected output of the entire sequence of transforms when they are run against the + // input Username and Groups. + Expects FederationDomainTransformsExampleExpects `json:"expects"` +} + +// FederationDomainTransformsExampleExpects defines the expected result for a transforms example. +type FederationDomainTransformsExampleExpects struct { + // Username is the expected username after the transformations have been applied. + // +optional + Username string `json:"username,omitempty"` + + // Groups is the expected list of group names after the transformations have been applied. + // +optional + Groups []string `json:"groups,omitempty"` + + // Rejected is a boolean that indicates whether authentication is expected to be rejected by a policy expression + // after the transformations have been applied. True means that it is expected that the authentication would be + // rejected. The default value of false means that it is expected that the authentication would not be rejected + // by any policy expression. + // +optional + Rejected bool `json:"rejected,omitempty"` + + // Message is the expected error message of the transforms. When Rejected is true, then Message is the expected + // message for the policy which rejected the authentication attempt. When Rejected is true and Message is blank, + // then Message will be treated as the default error message for authentication attempts which are rejected by a + // policy. When Rejected is false, then Message is the expected error message for some other non-policy + // transformation error, such as a runtime error. When Rejected is false, there is no default expected Message. + // +optional + Message string `json:"message,omitempty"` +} + +// FederationDomainTransforms defines identity transformations for an identity provider's usage on a FederationDomain. +type FederationDomainTransforms struct { + // Constants defines constant variables and their values which will be made available to the transform expressions. + // +patchMergeKey=name + // +patchStrategy=merge + // +listType=map + // +listMapKey=name + // +optional + Constants []FederationDomainTransformsConstant `json:"constants,omitempty"` + + // Expressions are an optional list of transforms and policies to be executed in the order given during every + // authentication attempt, including during every session refresh. + // Each is a CEL expression. It may use the basic CEL language as defined in + // https://github.com/google/cel-spec/blob/master/doc/langdef.md plus the CEL string extensions defined in + // https://github.com/google/cel-go/tree/master/ext#strings. + // + // The username and groups extracted from the identity provider, and the constants defined in this CR, are + // available as variables in all expressions. The username is provided via a variable called `username` and + // the list of group names is provided via a variable called `groups` (which may be an empty list). + // Each user-provided constants is provided via a variable named `strConst.varName` for string constants + // and `strListConst.varName` for string list constants. + // + // The only allowed types for expressions are currently policy/v1, username/v1, and groups/v1. + // Each policy/v1 must return a boolean, and when it returns false, no more expressions from the list are evaluated + // and the authentication attempt is rejected. + // Transformations of type policy/v1 do not return usernames or group names, and therefore cannot change the + // username or group names. + // Each username/v1 transform must return the new username (a string), which can be the same as the old username. + // Transformations of type username/v1 do not return group names, and therefore cannot change the group names. + // Each groups/v1 transform must return the new groups list (list of strings), which can be the same as the old + // groups list. + // Transformations of type groups/v1 do not return usernames, and therefore cannot change the usernames. + // After each expression, the new (potentially changed) username or groups get passed to the following expression. + // + // Any compilation or static type-checking failure of any expression will cause an error status on the FederationDomain. + // During an authentication attempt, any unexpected runtime evaluation errors (e.g. division by zero) cause the + // authentication attempt to fail. When all expressions evaluate successfully, then the (potentially changed) username + // and group names have been decided for that authentication attempt. + // + // +optional + Expressions []FederationDomainTransformsExpression `json:"expressions,omitempty"` + + // Examples can optionally be used to ensure that the sequence of transformation expressions are working as + // expected. Examples define sample input identities which are then run through the expression list, and the + // results are compared to the expected results. If any example in this list fails, then this + // identity provider will not be available for use within this FederationDomain, and the error(s) will be + // added to the FederationDomain status. This can be used to help guard against programming mistakes in the + // expressions, and also act as living documentation for other administrators to better understand the expressions. + // +optional + Examples []FederationDomainTransformsExample `json:"examples,omitempty"` +} + +// FederationDomainIdentityProvider describes how an identity provider is made available in this FederationDomain. +type FederationDomainIdentityProvider struct { + // DisplayName is the name of this identity provider as it will appear to clients. This name ends up in the + // kubeconfig of end users, so changing the name of an identity provider that is in use by end users will be a + // disruptive change for those users. + // +kubebuilder:validation:MinLength=1 + DisplayName string `json:"displayName"` + + // ObjectRef is a reference to a Pinniped identity provider resource. A valid reference is required. + // If the reference cannot be resolved then the identity provider will not be made available. + // Must refer to a resource of one of the Pinniped identity provider types, e.g. OIDCIdentityProvider, + // LDAPIdentityProvider, ActiveDirectoryIdentityProvider. + ObjectRef corev1.TypedLocalObjectReference `json:"objectRef"` + + // Transforms is an optional way to specify transformations to be applied during user authentication and + // session refresh. + // +optional + Transforms FederationDomainTransforms `json:"transforms,omitempty"` +} + // FederationDomainSpec is a struct that describes an OIDC Provider. type FederationDomainSpec struct { // Issuer is the OIDC Provider's issuer, per the OIDC Discovery Metadata document, as well as the @@ -55,9 +209,35 @@ type FederationDomainSpec struct { // +kubebuilder:validation:MinLength=1 Issuer string `json:"issuer"` - // TLS configures how this FederationDomain is served over Transport Layer Security (TLS). + // TLS specifies a secret which will contain Transport Layer Security (TLS) configuration for the FederationDomain. // +optional TLS *FederationDomainTLSSpec `json:"tls,omitempty"` + + // IdentityProviders is the list of identity providers available for use by this FederationDomain. + // + // An identity provider CR (e.g. OIDCIdentityProvider or LDAPIdentityProvider) describes how to connect to a server, + // how to talk in a specific protocol for authentication, and how to use the schema of that server/protocol to + // extract a normalized user identity. Normalized user identities include a username and a list of group names. + // In contrast, IdentityProviders describes how to use that normalized identity in those Kubernetes clusters which + // belong to this FederationDomain. Each entry in IdentityProviders can be configured with arbitrary transformations + // on that normalized identity. For example, a transformation can add a prefix to all usernames to help avoid + // accidental conflicts when multiple identity providers have different users with the same username (e.g. + // "idp1:ryan" versus "idp2:ryan"). Each entry in IdentityProviders can also implement arbitrary authentication + // rejection policies. Even though a user was able to authenticate with the identity provider, a policy can disallow + // the authentication to the Kubernetes clusters that belong to this FederationDomain. For example, a policy could + // disallow the authentication unless the user belongs to a specific group in the identity provider. + // + // For backwards compatibility with versions of Pinniped which predate support for multiple identity providers, + // an empty IdentityProviders list will cause the FederationDomain to use all available identity providers which + // exist in the same namespace, but also to reject all authentication requests when there is more than one identity + // provider currently defined. In this backwards compatibility mode, the name of the identity provider resource + // (e.g. the Name of an OIDCIdentityProvider resource) will be used as the name of the identity provider in this + // FederationDomain. This mode is provided to make upgrading from older versions easier. However, instead of + // relying on this backwards compatibility mode, please consider this mode to be deprecated and please instead + // explicitly list the identity provider using this IdentityProviders field. + // + // +optional + IdentityProviders []FederationDomainIdentityProvider `json:"identityProviders,omitempty"` } // FederationDomainSecrets holds information about this OIDC Provider's secrets. @@ -86,20 +266,17 @@ type FederationDomainSecrets struct { // FederationDomainStatus is a struct that describes the actual state of an OIDC Provider. type FederationDomainStatus struct { - // Status holds an enum that describes the state of this OIDC Provider. Note that this Status can - // represent success or failure. - // +optional - Status FederationDomainStatusCondition `json:"status,omitempty"` + // Phase summarizes the overall status of the FederationDomain. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase FederationDomainPhase `json:"phase,omitempty"` - // Message provides human-readable details about the Status. - // +optional - Message string `json:"message,omitempty"` - - // LastUpdateTime holds the time at which the Status was last updated. It is a pointer to get - // around some undesirable behavior with respect to the empty metav1.Time value (see - // https://github.com/kubernetes/kubernetes/issues/86811). - // +optional - LastUpdateTime *metav1.Time `json:"lastUpdateTime,omitempty"` + // Conditions represent the observations of an FederationDomain's current state. + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // Secrets contains information about this OIDC Provider's secrets. // +optional @@ -111,7 +288,7 @@ type FederationDomainStatus struct { // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped // +kubebuilder:printcolumn:name="Issuer",type=string,JSONPath=`.spec.issuer` -// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.status` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type FederationDomain struct { diff --git a/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go index 48f5de37..61106fdb 100644 --- a/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -8,14 +8,14 @@ import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" type OIDCClientPhase string const ( - // PhasePending is the default phase for newly-created OIDCClient resources. - PhasePending OIDCClientPhase = "Pending" + // OIDCClientPhasePending is the default phase for newly-created OIDCClient resources. + OIDCClientPhasePending OIDCClientPhase = "Pending" - // PhaseReady is the phase for an OIDCClient resource in a healthy state. - PhaseReady OIDCClientPhase = "Ready" + // OIDCClientPhaseReady is the phase for an OIDCClient resource in a healthy state. + OIDCClientPhaseReady OIDCClientPhase = "Ready" - // PhaseError is the phase for an OIDCClient in an unhealthy state. - PhaseError OIDCClientPhase = "Error" + // OIDCClientPhaseError is the phase for an OIDCClient in an unhealthy state. + OIDCClientPhaseError OIDCClientPhase = "Error" ) // +kubebuilder:validation:Pattern=`^https://.+|^http://(127\.0\.0\.1|\[::1\])(:\d+)?/` diff --git a/generated/latest/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go index 551886ce..018a3023 100644 --- a/generated/latest/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/config/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 @@ -40,6 +41,24 @@ func (in *FederationDomain) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainIdentityProvider) DeepCopyInto(out *FederationDomainIdentityProvider) { + *out = *in + in.ObjectRef.DeepCopyInto(&out.ObjectRef) + in.Transforms.DeepCopyInto(&out.Transforms) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainIdentityProvider. +func (in *FederationDomainIdentityProvider) DeepCopy() *FederationDomainIdentityProvider { + if in == nil { + return nil + } + out := new(FederationDomainIdentityProvider) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainList) DeepCopyInto(out *FederationDomainList) { *out = *in @@ -101,6 +120,13 @@ func (in *FederationDomainSpec) DeepCopyInto(out *FederationDomainSpec) { *out = new(FederationDomainTLSSpec) **out = **in } + if in.IdentityProviders != nil { + in, out := &in.IdentityProviders, &out.IdentityProviders + *out = make([]FederationDomainIdentityProvider, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } return } @@ -117,9 +143,12 @@ func (in *FederationDomainSpec) DeepCopy() *FederationDomainSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FederationDomainStatus) DeepCopyInto(out *FederationDomainStatus) { *out = *in - if in.LastUpdateTime != nil { - in, out := &in.LastUpdateTime, &out.LastUpdateTime - *out = (*in).DeepCopy() + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } } out.Secrets = in.Secrets return @@ -151,6 +180,121 @@ func (in *FederationDomainTLSSpec) DeepCopy() *FederationDomainTLSSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransforms) DeepCopyInto(out *FederationDomainTransforms) { + *out = *in + if in.Constants != nil { + in, out := &in.Constants, &out.Constants + *out = make([]FederationDomainTransformsConstant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Expressions != nil { + in, out := &in.Expressions, &out.Expressions + *out = make([]FederationDomainTransformsExpression, len(*in)) + copy(*out, *in) + } + if in.Examples != nil { + in, out := &in.Examples, &out.Examples + *out = make([]FederationDomainTransformsExample, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransforms. +func (in *FederationDomainTransforms) DeepCopy() *FederationDomainTransforms { + if in == nil { + return nil + } + out := new(FederationDomainTransforms) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsConstant) DeepCopyInto(out *FederationDomainTransformsConstant) { + *out = *in + if in.StringListValue != nil { + in, out := &in.StringListValue, &out.StringListValue + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsConstant. +func (in *FederationDomainTransformsConstant) DeepCopy() *FederationDomainTransformsConstant { + if in == nil { + return nil + } + out := new(FederationDomainTransformsConstant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExample) DeepCopyInto(out *FederationDomainTransformsExample) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + in.Expects.DeepCopyInto(&out.Expects) + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExample. +func (in *FederationDomainTransformsExample) DeepCopy() *FederationDomainTransformsExample { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExample) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExampleExpects) DeepCopyInto(out *FederationDomainTransformsExampleExpects) { + *out = *in + if in.Groups != nil { + in, out := &in.Groups, &out.Groups + *out = make([]string, len(*in)) + copy(*out, *in) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExampleExpects. +func (in *FederationDomainTransformsExampleExpects) DeepCopy() *FederationDomainTransformsExampleExpects { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExampleExpects) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederationDomainTransformsExpression) DeepCopyInto(out *FederationDomainTransformsExpression) { + *out = *in + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederationDomainTransformsExpression. +func (in *FederationDomainTransformsExpression) DeepCopy() *FederationDomainTransformsExpression { + if in == nil { + return nil + } + out := new(FederationDomainTransformsExpression) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OIDCClient) DeepCopyInto(out *OIDCClient) { *out = *in diff --git a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go index 4a1a2c03..ad0a3806 100644 --- a/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go +++ b/generated/latest/apis/supervisor/idp/v1alpha1/zz_generated.deepcopy.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 diff --git a/generated/latest/client/supervisor/openapi/zz_generated.openapi.go b/generated/latest/client/supervisor/openapi/zz_generated.openapi.go index d525b906..6612e548 100644 --- a/generated/latest/client/supervisor/openapi/zz_generated.openapi.go +++ b/generated/latest/client/supervisor/openapi/zz_generated.openapi.go @@ -1,4 +1,5 @@ //go:build !ignore_autogenerated +// +build !ignore_autogenerated // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 @@ -21,58 +22,58 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "go.pinniped.dev/generated/latest/apis/supervisor/clientsecret/v1alpha1.OIDCClientSecretRequestList": schema_apis_supervisor_clientsecret_v1alpha1_OIDCClientSecretRequestList(ref), "go.pinniped.dev/generated/latest/apis/supervisor/clientsecret/v1alpha1.OIDCClientSecretRequestSpec": schema_apis_supervisor_clientsecret_v1alpha1_OIDCClientSecretRequestSpec(ref), "go.pinniped.dev/generated/latest/apis/supervisor/clientsecret/v1alpha1.OIDCClientSecretRequestStatus": schema_apis_supervisor_clientsecret_v1alpha1_OIDCClientSecretRequestStatus(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref), - "k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref), - "k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), - "k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), - "k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), - "k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIResourceList": schema_pkg_apis_meta_v1_APIResourceList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.APIVersions": schema_pkg_apis_meta_v1_APIVersions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ApplyOptions": schema_pkg_apis_meta_v1_ApplyOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Condition": schema_pkg_apis_meta_v1_Condition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.CreateOptions": schema_pkg_apis_meta_v1_CreateOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.DeleteOptions": schema_pkg_apis_meta_v1_DeleteOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Duration": schema_pkg_apis_meta_v1_Duration(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.FieldsV1": schema_pkg_apis_meta_v1_FieldsV1(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GetOptions": schema_pkg_apis_meta_v1_GetOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupKind": schema_pkg_apis_meta_v1_GroupKind(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupResource": schema_pkg_apis_meta_v1_GroupResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersion": schema_pkg_apis_meta_v1_GroupVersion(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionForDiscovery": schema_pkg_apis_meta_v1_GroupVersionForDiscovery(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionKind": schema_pkg_apis_meta_v1_GroupVersionKind(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.GroupVersionResource": schema_pkg_apis_meta_v1_GroupVersionResource(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent": schema_pkg_apis_meta_v1_InternalEvent(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector": schema_pkg_apis_meta_v1_LabelSelector(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelectorRequirement": schema_pkg_apis_meta_v1_LabelSelectorRequirement(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.List": schema_pkg_apis_meta_v1_List(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta": schema_pkg_apis_meta_v1_ListMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ListOptions": schema_pkg_apis_meta_v1_ListOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ManagedFieldsEntry": schema_pkg_apis_meta_v1_ManagedFieldsEntry(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.MicroTime": schema_pkg_apis_meta_v1_MicroTime(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta": schema_pkg_apis_meta_v1_ObjectMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.OwnerReference": schema_pkg_apis_meta_v1_OwnerReference(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadata": schema_pkg_apis_meta_v1_PartialObjectMetadata(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PartialObjectMetadataList": schema_pkg_apis_meta_v1_PartialObjectMetadataList(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Patch": schema_pkg_apis_meta_v1_Patch(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.PatchOptions": schema_pkg_apis_meta_v1_PatchOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Preconditions": schema_pkg_apis_meta_v1_Preconditions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.RootPaths": schema_pkg_apis_meta_v1_RootPaths(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.ServerAddressByClientCIDR": schema_pkg_apis_meta_v1_ServerAddressByClientCIDR(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Status": schema_pkg_apis_meta_v1_Status(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.StatusCause": schema_pkg_apis_meta_v1_StatusCause(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.StatusDetails": schema_pkg_apis_meta_v1_StatusDetails(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Table": schema_pkg_apis_meta_v1_Table(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableColumnDefinition": schema_pkg_apis_meta_v1_TableColumnDefinition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableOptions": schema_pkg_apis_meta_v1_TableOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableRow": schema_pkg_apis_meta_v1_TableRow(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TableRowCondition": schema_pkg_apis_meta_v1_TableRowCondition(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Time": schema_pkg_apis_meta_v1_Time(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.Timestamp": schema_pkg_apis_meta_v1_Timestamp(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.TypeMeta": schema_pkg_apis_meta_v1_TypeMeta(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.UpdateOptions": schema_pkg_apis_meta_v1_UpdateOptions(ref), + "k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent": schema_pkg_apis_meta_v1_WatchEvent(ref), + "k8s.io/apimachinery/pkg/runtime.RawExtension": schema_k8sio_apimachinery_pkg_runtime_RawExtension(ref), + "k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref), + "k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref), + "k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref), } } diff --git a/go.mod b/go.mod index 83e99e9e..2f8185f7 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-logr/zapr v1.2.4 github.com/gofrs/flock v0.8.1 github.com/golang/mock v1.6.0 + github.com/google/cel-go v0.16.0 github.com/google/go-cmp v0.5.9 github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.3.1 @@ -90,7 +91,6 @@ require ( github.com/golang/glog v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect - github.com/google/cel-go v0.16.0 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect diff --git a/hack/integration-test-env-goland.sh b/hack/integration-test-env-goland.sh index db2c0bc4..c4991627 100755 --- a/hack/integration-test-env-goland.sh +++ b/hack/integration-test-env-goland.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -17,7 +17,9 @@ echo -n "PINNIPED_TEST_GOLAND_RUNNER=true;" printenv | grep PINNIPED_TEST_ | sed 's/=.*//g' | grep -v CLUSTER_CAPABILITY_YAML | while read -r var ; do echo -n "${var}=" - echo -n "${!var}" | tr -d '\n' + # Goland will treat semicolons as key/value pair separators. + # Within a value, a semicolon needs to be escaped with a backslash for Goland. + echo -n "${!var}" | sed 's/;/\\;/g' | tr -d '\n' echo -n ";" done diff --git a/hack/lib/update-codegen.sh b/hack/lib/update-codegen.sh index fdf2a8d1..8be08847 100755 --- a/hack/lib/update-codegen.sh +++ b/hack/lib/update-codegen.sh @@ -40,6 +40,9 @@ if [[ "${#KUBE_VERSIONS[@]}" -ne 1 ]]; then exit 1 fi +# Add this to the git config inside the container to avoid permission errors when running this script on linux. +git config --global --add safe.directory /work + # Link the root directory into GOPATH since that is where output ends up. GOPATH_ROOT="${GOPATH}/src/${BASE_PKG}" mkdir -p "$(dirname "${GOPATH_ROOT}")" diff --git a/hack/prepare-supervisor-on-kind.sh b/hack/prepare-supervisor-on-kind.sh index a56e5970..91f9804a 100755 --- a/hack/prepare-supervisor-on-kind.sh +++ b/hack/prepare-supervisor-on-kind.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +# Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -81,7 +81,7 @@ while (("$#")); do done if [[ "$use_oidc_upstream" == "no" && "$use_ldap_upstream" == "no" && "$use_ad_upstream" == "no" ]]; then - log_error "Error: Please use --oidc, --ldap, or --ad to specify which type of upstream identity provider(s) you would like" + log_error "Error: Please use --oidc, --ldap, or --ad to specify which type(s) of upstream identity provider(s) you would like. May use one or multiple." exit 1 fi @@ -103,42 +103,6 @@ audience="my-workload-cluster-$(openssl rand -hex 4)" issuer_host="pinniped-supervisor-clusterip.supervisor.svc.cluster.local" issuer="https://$issuer_host/some/path" -# Create a CA and TLS serving certificates for the Supervisor. -step certificate create \ - "Supervisor CA" "$root_ca_crt_path" "$root_ca_key_path" \ - --profile root-ca \ - --no-password --insecure --force -step certificate create \ - "$issuer_host" "$tls_crt_path" "$tls_key_path" \ - --profile leaf \ - --not-after 8760h \ - --ca "$root_ca_crt_path" --ca-key "$root_ca_key_path" \ - --no-password --insecure --force - -# Put the TLS certificate into a Secret for the Supervisor. -kubectl create secret tls -n "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" my-federation-domain-tls --cert "$tls_crt_path" --key "$tls_key_path" \ - --dry-run=client --output yaml | kubectl apply -f - - -# Make a FederationDomain using the TLS Secret from above. -cat < $fd_file +apiVersion: config.supervisor.pinniped.dev/v1alpha1 +kind: FederationDomain +metadata: + name: my-federation-domain +spec: + issuer: $issuer + tls: + secretName: my-federation-domain-tls + identityProviders: +EOF + +if [[ "$use_oidc_upstream" == "yes" ]]; then + # Indenting the heredoc by 4 spaces to make it indented the correct amount in the FederationDomain below. + cat << EOF >> $fd_file + + - displayName: "My OIDC IDP 🚀" + objectRef: + apiGroup: idp.supervisor.pinniped.dev + kind: OIDCIdentityProvider + name: my-oidc-provider + transforms: + expressions: + - type: username/v1 + expression: '"oidc:" + username' + - type: groups/v1 # the pinny user doesn't belong to any groups in Dex, so this isn't strictly needed, but doesn't hurt + expression: 'groups.map(group, "oidc:" + group)' + examples: + - username: ryan@example.com + groups: [ a, b ] + expects: + username: oidc:ryan@example.com + groups: [ oidc:a, oidc:b ] +EOF +fi + +if [[ "$use_ldap_upstream" == "yes" ]]; then + # Indenting the heredoc by 4 spaces to make it indented the correct amount in the FederationDomain below. + cat << EOF >> $fd_file + + - displayName: "My LDAP IDP 🚀" + objectRef: + apiGroup: idp.supervisor.pinniped.dev + kind: LDAPIdentityProvider + name: my-ldap-provider + transforms: # these are contrived to exercise all the available features + constants: + - name: prefix + type: string + stringValue: "ldap:" + - name: onlyIncludeGroupsWithThisPrefix + type: string + stringValue: "ball-" # pinny belongs to ball-game-players in openldap + - name: mustBelongToOneOfThese + type: stringList + stringListValue: [ ball-admins, seals ] # pinny belongs to seals in openldap + - name: additionalAdmins + type: stringList + stringListValue: [ pinny.ldap@example.com, ryan@example.com ] # pinny's email address in openldap + expressions: + - type: policy/v1 + expression: 'groups.exists(g, g in strListConst.mustBelongToOneOfThese)' + message: "Only users in certain kube groups are allowed to authenticate" + - type: groups/v1 + expression: 'username in strListConst.additionalAdmins ? groups + ["ball-admins"] : groups' + - type: groups/v1 + expression: 'groups.filter(group, group.startsWith(strConst.onlyIncludeGroupsWithThisPrefix))' + - type: username/v1 + expression: 'strConst.prefix + username' + - type: groups/v1 + expression: 'groups.map(group, strConst.prefix + group)' + examples: + - username: ryan@example.com + groups: [ ball-developers, seals, non-ball-group ] # allowed to auth because belongs to seals + expects: + username: ldap:ryan@example.com + groups: [ ldap:ball-developers, ldap:ball-admins ] # gets ball-admins because of username, others dropped because they lack "ball-" prefix + - username: someone_else@example.com + groups: [ ball-developers, ball-admins, non-ball-group ] # allowed to auth because belongs to ball-admins + expects: + username: ldap:someone_else@example.com + groups: [ ldap:ball-developers, ldap:ball-admins ] # seals dropped because it lacks prefix + - username: paul@example.com + groups: [ not-ball-admins-group, not-seals-group ] # reject because does not belong to any of the required groups + expects: + rejected: true + message: "Only users in certain kube groups are allowed to authenticate" +EOF +fi + +if [[ "$use_ad_upstream" == "yes" ]]; then + # Indenting the heredoc by 4 spaces to make it indented the correct amount in the FederationDomain below. + cat << EOF >> $fd_file + + - displayName: "My AD IDP" + objectRef: + apiGroup: idp.supervisor.pinniped.dev + kind: ActiveDirectoryIdentityProvider + name: my-ad-provider +EOF +fi + +# Apply the FederationDomain from the file created above. +kubectl apply --namespace "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" -f "$fd_file" + +echo "Waiting for FederationDomain to initialize or update..." +kubectl wait --for=condition=Ready FederationDomain/my-federation-domain -n "$PINNIPED_TEST_SUPERVISOR_NAMESPACE" + +# Test that the federation domain is working before we proceed. +echo "Fetching FederationDomain discovery info via command: https_proxy=\"$PINNIPED_TEST_PROXY\" curl -fLsS --cacert \"$root_ca_crt_path\" \"$issuer/.well-known/openid-configuration\"" +https_proxy="$PINNIPED_TEST_PROXY" curl -fLsS --cacert "$root_ca_crt_path" "$issuer/.well-known/openid-configuration" | jq . + if [[ "$OSTYPE" == "darwin"* ]]; then certificateAuthorityData=$(cat "$root_ca_crt_path" | base64) else @@ -275,7 +373,7 @@ spec: certificateAuthorityData: $certificateAuthorityData EOF -echo "Waiting for JWTAuthenticator to initialize..." +echo "Waiting for JWTAuthenticator to initialize or update..." # Sleeping is a race, but that's probably good enough for the purposes of this script. sleep 5 @@ -288,12 +386,24 @@ while [[ -z "$(kubectl get credentialissuer pinniped-concierge-config -o=jsonpat sleep 2 done -# Use the CLI to get the kubeconfig. Tell it that you don't want the browser to automatically open for logins. +# Use the CLI to get the kubeconfig. Tell it that you don't want the browser to automatically open for browser-based +# flows so we can open our own browser with the proxy settings. Generate a kubeconfig for each IDP. flow_arg="" if [[ -n "$use_flow" ]]; then flow_arg="--upstream-identity-provider-flow $use_flow" fi -https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" ./pinniped get kubeconfig --oidc-skip-browser $flow_arg >kubeconfig +if [[ "$use_oidc_upstream" == "yes" ]]; then + https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" \ + ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type oidc >kubeconfig-oidc.yaml +fi +if [[ "$use_ldap_upstream" == "yes" ]]; then + https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" \ + ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type ldap >kubeconfig-ldap.yaml +fi +if [[ "$use_ad_upstream" == "yes" ]]; then + https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" \ + ./pinniped get kubeconfig --oidc-skip-browser $flow_arg --upstream-identity-provider-type activedirectory >kubeconfig-ad.yaml +fi # Clear the local CLI cache to ensure that the kubectl command below will need to perform a fresh login. rm -f "$HOME/.config/pinniped/sessions.yaml" @@ -304,37 +414,48 @@ echo "Ready! 🚀" if [[ "$use_oidc_upstream" == "yes" || "$use_flow" == "browser_authcode" ]]; then echo - echo "To be able to access the login URL shown below, start Chrome like this:" + echo "To be able to access the Supervisor URL during login, start Chrome like this:" echo " open -a \"Google Chrome\" --args --proxy-server=\"$PINNIPED_TEST_PROXY\"" echo "Note that Chrome must be fully quit before being started with --proxy-server." echo "Then open the login URL shown below in that new Chrome window." echo echo "When prompted for username and password, use these values:" + echo fi if [[ "$use_oidc_upstream" == "yes" ]]; then - echo " Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" - echo " Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD" + echo " OIDC Username: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_USERNAME" + echo " OIDC Password: $PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_PASSWORD" + echo fi if [[ "$use_ldap_upstream" == "yes" ]]; then - echo " Username: $PINNIPED_TEST_LDAP_USER_CN" - echo " Password: $PINNIPED_TEST_LDAP_USER_PASSWORD" + echo " LDAP Username: $PINNIPED_TEST_LDAP_USER_CN" + echo " LDAP Password: $PINNIPED_TEST_LDAP_USER_PASSWORD" + echo fi if [[ "$use_ad_upstream" == "yes" ]]; then - echo " Username: $PINNIPED_TEST_AD_USER_USER_PRINCIPAL_NAME" - echo " Password: $PINNIPED_TEST_AD_USER_PASSWORD" + echo " AD Username: $PINNIPED_TEST_AD_USER_USER_PRINCIPAL_NAME" + echo " AD Password: $PINNIPED_TEST_AD_USER_PASSWORD" + echo fi -# Perform a login using the kubectl plugin. This should print the URL to be followed for the Dex login page -# if using an OIDC upstream, or should prompt on the CLI for username/password if using an LDAP upstream. -echo -echo "Running: PINNIPED_DEBUG=true https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" kubectl --kubeconfig ./kubeconfig get pods -A" -PINNIPED_DEBUG=true https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" kubectl --kubeconfig ./kubeconfig get pods -A - -# Print the identity of the currently logged in user. The CLI has cached your tokens, and will automatically refresh -# your short-lived credentials whenever they expire, so you should not be prompted to log in again for the rest of the day. -echo -echo "Running: PINNIPED_DEBUG=true https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" ./pinniped whoami --kubeconfig ./kubeconfig" -PINNIPED_DEBUG=true https_proxy="$PINNIPED_TEST_PROXY" no_proxy="127.0.0.1" ./pinniped whoami --kubeconfig ./kubeconfig +# Echo the commands that may be used to login and print the identity of the currently logged in user. +# Once the CLI has cached your tokens, it will automatically refresh your short-lived credentials whenever +# they expire, so you should not be prompted to log in again for the rest of the day. +if [[ "$use_oidc_upstream" == "yes" ]]; then + echo "To log in using OIDC, run:" + echo "PINNIPED_DEBUG=true https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" ./pinniped whoami --kubeconfig ./kubeconfig-oidc.yaml" + echo +fi +if [[ "$use_ldap_upstream" == "yes" ]]; then + echo "To log in using LDAP, run:" + echo "PINNIPED_DEBUG=true https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" ./pinniped whoami --kubeconfig ./kubeconfig-ldap.yaml" + echo +fi +if [[ "$use_ad_upstream" == "yes" ]]; then + echo "To log in using AD, run:" + echo "PINNIPED_DEBUG=true https_proxy=\"$PINNIPED_TEST_PROXY\" no_proxy=\"127.0.0.1\" ./pinniped whoami --kubeconfig ./kubeconfig-ad.yaml" + echo +fi diff --git a/internal/celtransformer/celformer.go b/internal/celtransformer/celformer.go new file mode 100644 index 00000000..e88b5bfd --- /dev/null +++ b/internal/celtransformer/celformer.go @@ -0,0 +1,359 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package celtransformer is an implementation of upstream-to-downstream identity transformations +// and policies using CEL scripts. +// +// The CEL language is documented in https://github.com/google/cel-spec/blob/master/doc/langdef.md +// with optional extensions documented in https://github.com/google/cel-go/tree/master/ext. +package celtransformer + +import ( + "context" + "fmt" + "reflect" + "regexp" + "strings" + "time" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types/ref" + "github.com/google/cel-go/ext" + + "go.pinniped.dev/internal/idtransform" +) + +const ( + usernameVariableName = "username" + groupsVariableName = "groups" + constStringVariableName = "strConst" + constStringListVariableName = "strListConst" + + DefaultPolicyRejectedAuthMessage = "authentication was rejected by a configured policy" +) + +// CELTransformer can compile any number of transformation expression pipelines. +// Each compiled pipeline can be cached in memory for later thread-safe evaluation. +type CELTransformer struct { + compiler *cel.Env + maxExpressionRuntime time.Duration +} + +// NewCELTransformer returns a CELTransformer. +// A running process should only need one instance of a CELTransformer. +func NewCELTransformer(maxExpressionRuntime time.Duration) (*CELTransformer, error) { + env, err := newEnv() + if err != nil { + return nil, err + } + return &CELTransformer{compiler: env, maxExpressionRuntime: maxExpressionRuntime}, nil +} + +// TransformationConstants can be used to make more variables available to compiled CEL expressions for convenience. +type TransformationConstants struct { + // A map of variable names to their string values. If a key "x" has value "123", then it will be available + // to CEL expressions as the variable `strConst.x` with value `"123"`. + StringConstants map[string]string + // A map of variable names to their string list values. If a key "x" has value []string{"123","456"}, + // then it will be available to CEL expressions as the variable `strListConst.x` with value `["123","456"]`. + StringListConstants map[string][]string +} + +// Valid identifiers in CEL expressions are defined as [_a-zA-Z][_a-zA-Z0-9]* by the CEL language spec. +var validIdentifiersRegexp = regexp.MustCompile(`^[_a-zA-Z][_a-zA-Z0-9]*$`) + +func (t *TransformationConstants) validateVariableNames() error { + const errFormat = "%q is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)" + for k := range t.StringConstants { + if !validIdentifiersRegexp.MatchString(k) { + return fmt.Errorf(errFormat, k) + } + } + for k := range t.StringListConstants { + if !validIdentifiersRegexp.MatchString(k) { + return fmt.Errorf(errFormat, k) + } + } + return nil +} + +// CompileTransformation compiles a CEL-based identity transformation expression. +// The compiled transform can be cached in memory and executed repeatedly and in a thread-safe way. +// The caller must not modify the consts param struct after calling this function to allow +// the returned IdentityTransformation to use it as a thread-safe read-only structure. +func (c *CELTransformer) CompileTransformation(t CELTransformation, consts *TransformationConstants) (idtransform.IdentityTransformation, error) { + if consts == nil { + consts = &TransformationConstants{} + } + if err := consts.validateVariableNames(); err != nil { + return nil, err + } + return t.compile(c, consts) +} + +// CELTransformation can be compiled into an IdentityTransformation. +type CELTransformation interface { + compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) +} + +var _ CELTransformation = (*UsernameTransformation)(nil) +var _ CELTransformation = (*GroupsTransformation)(nil) +var _ CELTransformation = (*AllowAuthenticationPolicy)(nil) + +// UsernameTransformation is a CEL expression that can transform a username (or leave it unchanged). +// It implements CELTransformation. +type UsernameTransformation struct { + Expression string +} + +// GroupsTransformation is a CEL expression that can transform a list of group names (or leave it unchanged). +// It implements CELTransformation. +type GroupsTransformation struct { + Expression string +} + +// AllowAuthenticationPolicy is a CEL expression that can allow the authentication to proceed by returning true. +// It implements CELTransformation. When the CEL expression returns false, the authentication is rejected and the +// RejectedAuthenticationMessage is used. When RejectedAuthenticationMessage is empty, a default message will be +// used for rejected authentications. +type AllowAuthenticationPolicy struct { + Expression string + RejectedAuthenticationMessage string +} + +func compileProgram(transformer *CELTransformer, expectedExpressionType *cel.Type, expr string) (cel.Program, error) { + if strings.TrimSpace(expr) == "" { + return nil, fmt.Errorf("cannot compile empty CEL expression") + } + + // compile does both parsing and type checking. The parsing phase indicates whether the expression is + // syntactically valid and expands any macros present within the environment. Parsing and checking are + // more computationally expensive than evaluation, so parsing and checking are done in advance. + ast, issues := transformer.compiler.Compile(expr) + if issues != nil { + return nil, fmt.Errorf("CEL expression compile error: %s", issues.String()) + } + + // The compiler's type checker has determined the type of the expression's result. + // Check that it matches the type that we expect. + if ast.OutputType().String() != expectedExpressionType.String() { + return nil, fmt.Errorf("CEL expression should return type %q but returns type %q", expectedExpressionType, ast.OutputType()) + } + + // The cel.Program is stateless, thread-safe, and cachable. + program, err := transformer.compiler.Program(ast, + cel.InterruptCheckFrequency(100), // Kubernetes uses 100 here, so we'll copy that setting. + cel.EvalOptions(cel.OptOptimize), // Optimize certain things now rather than at evaluation time. + ) + if err != nil { + return nil, fmt.Errorf("CEL expression program construction error: %w", err) + } + return program, nil +} + +func (t *UsernameTransformation) compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) { + program, err := compileProgram(transformer, cel.StringType, t.Expression) + if err != nil { + return nil, err + } + return &compiledUsernameTransformation{ + baseCompiledTransformation: &baseCompiledTransformation{ + program: program, + consts: consts, + sourceExpr: t, + maxExpressionRuntime: transformer.maxExpressionRuntime, + }, + }, nil +} + +func (t *GroupsTransformation) compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) { + program, err := compileProgram(transformer, cel.ListType(cel.StringType), t.Expression) + if err != nil { + return nil, err + } + return &compiledGroupsTransformation{ + baseCompiledTransformation: &baseCompiledTransformation{ + program: program, + consts: consts, + sourceExpr: t, + maxExpressionRuntime: transformer.maxExpressionRuntime, + }, + }, nil +} + +func (t *AllowAuthenticationPolicy) compile(transformer *CELTransformer, consts *TransformationConstants) (idtransform.IdentityTransformation, error) { + program, err := compileProgram(transformer, cel.BoolType, t.Expression) + if err != nil { + return nil, err + } + return &compiledAllowAuthenticationPolicy{ + baseCompiledTransformation: &baseCompiledTransformation{ + program: program, + consts: consts, + sourceExpr: t, + maxExpressionRuntime: transformer.maxExpressionRuntime, + }, + rejectedAuthenticationMessage: t.RejectedAuthenticationMessage, + }, nil +} + +// Base type for common aspects of compiled transformations. +type baseCompiledTransformation struct { + program cel.Program + consts *TransformationConstants + sourceExpr CELTransformation + maxExpressionRuntime time.Duration +} + +// Implements idtransform.IdentityTransformation. +type compiledUsernameTransformation struct { + *baseCompiledTransformation +} + +// Implements idtransform.IdentityTransformation. +type compiledGroupsTransformation struct { + *baseCompiledTransformation +} + +// Implements idtransform.IdentityTransformation. +type compiledAllowAuthenticationPolicy struct { + *baseCompiledTransformation + rejectedAuthenticationMessage string +} + +func (c *baseCompiledTransformation) evalProgram(ctx context.Context, username string, groups []string) (ref.Val, error) { + // Limit the runtime of a CEL expression to avoid accidental very expensive expressions. + timeoutCtx, cancel := context.WithTimeout(ctx, c.maxExpressionRuntime) + defer cancel() + + // Evaluation is thread-safe and side effect free. Many inputs can be sent to the same cel.Program + // and if fields are present in the input, but not referenced in the expression, they are ignored. + // The argument to Eval may either be an `interpreter.Activation` or a `map[string]interface{}`. + val, _, err := c.program.ContextEval(timeoutCtx, map[string]interface{}{ + usernameVariableName: username, + groupsVariableName: groups, + constStringVariableName: c.consts.StringConstants, + constStringListVariableName: c.consts.StringListConstants, + }) + return val, err +} + +func (c *compiledUsernameTransformation) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) { + val, err := c.evalProgram(ctx, username, groups) + if err != nil { + return nil, err + } + nativeValue, err := val.ConvertToNative(reflect.TypeOf("")) + if err != nil { + return nil, fmt.Errorf("could not convert expression result to string: %w", err) + } + stringValue, ok := nativeValue.(string) + if !ok { + return nil, fmt.Errorf("could not convert expression result to string") + } + return &idtransform.TransformationResult{ + Username: stringValue, + Groups: groups, // groups are not modified by username transformations + AuthenticationAllowed: true, + }, nil +} + +func (c *compiledGroupsTransformation) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) { + val, err := c.evalProgram(ctx, username, groups) + if err != nil { + return nil, err + } + nativeValue, err := val.ConvertToNative(reflect.TypeOf([]string{})) + if err != nil { + return nil, fmt.Errorf("could not convert expression result to []string: %w", err) + } + stringSliceValue, ok := nativeValue.([]string) + if !ok { + return nil, fmt.Errorf("could not convert expression result to []string") + } + return &idtransform.TransformationResult{ + Username: username, // username is not modified by groups transformations + Groups: stringSliceValue, + AuthenticationAllowed: true, + }, nil +} + +func (c *compiledAllowAuthenticationPolicy) Evaluate(ctx context.Context, username string, groups []string) (*idtransform.TransformationResult, error) { + val, err := c.evalProgram(ctx, username, groups) + if err != nil { + return nil, err + } + nativeValue, err := val.ConvertToNative(reflect.TypeOf(true)) + if err != nil { + return nil, fmt.Errorf("could not convert expression result to bool: %w", err) + } + boolValue, ok := nativeValue.(bool) + if !ok { + return nil, fmt.Errorf("could not convert expression result to bool") + } + result := &idtransform.TransformationResult{ + Username: username, // username is not modified by policies + Groups: groups, // groups are not modified by policies + AuthenticationAllowed: boolValue, + } + if !boolValue { + if len(c.rejectedAuthenticationMessage) == 0 { + result.RejectedAuthenticationMessage = DefaultPolicyRejectedAuthMessage + } else { + result.RejectedAuthenticationMessage = c.rejectedAuthenticationMessage + } + } + return result, nil +} + +type CELTransformationSource struct { + Expr CELTransformation + Consts *TransformationConstants +} + +func (c *compiledUsernameTransformation) Source() interface{} { + return &CELTransformationSource{Expr: c.sourceExpr, Consts: c.consts} +} + +func (c *compiledGroupsTransformation) Source() interface{} { + return &CELTransformationSource{Expr: c.sourceExpr, Consts: c.consts} +} + +func (c *compiledAllowAuthenticationPolicy) Source() interface{} { + return &CELTransformationSource{Expr: c.sourceExpr, Consts: c.consts} +} + +func newEnv() (*cel.Env, error) { + // Note that Kubernetes uses CEL in several places, which are helpful to see as an example of + // how to configure the CEL compiler for production usage. Examples: + // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiserver/pkg/admission/plugin/validatingadmissionpolicy/compiler.go + // https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/compilation.go + return cel.NewEnv( + // Declare our variable without giving them values yet. By declaring them here, the type is known during + // the parsing/checking phase. + cel.Variable(usernameVariableName, cel.StringType), + cel.Variable(groupsVariableName, cel.ListType(cel.StringType)), + cel.Variable(constStringVariableName, cel.MapType(cel.StringType, cel.StringType)), + cel.Variable(constStringListVariableName, cel.MapType(cel.StringType, cel.ListType(cel.StringType))), + + // Enable the strings extensions. + // See https://github.com/google/cel-go/tree/master/ext#strings + // CEL also has other extensions for bas64 encoding/decoding and for math that we could choose to enable. + // See https://github.com/google/cel-go/tree/master/ext + // Kubernetes adds more extensions for extra regexp helpers, URLs, and extra list helpers that we could also + // consider enabling. Note that if we added their regexp extension, then we would also need to add + // cel.OptimizeRegex(library.ExtensionLibRegexOptimizations...) as an option when we call cel.Program. + // See https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/apiserver/pkg/cel/library + ext.Strings(), + + // Just in case someone converts a string to a timestamp, make any time operations which do not include + // an explicit timezone argument default to UTC. + cel.DefaultUTCTimeZone(true), + + // Check list and map literal entry types during type-checking. + cel.HomogeneousAggregateLiterals(), + + // Check for collisions in declarations now instead of later. + cel.EagerlyValidateDeclarations(true), + ) +} diff --git a/internal/celtransformer/celformer_test.go b/internal/celtransformer/celformer_test.go new file mode 100644 index 00000000..1e89d113 --- /dev/null +++ b/internal/celtransformer/celformer_test.go @@ -0,0 +1,900 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package celtransformer + +import ( + "context" + "fmt" + "runtime" + "sort" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/idtransform" +) + +func TestTransformer(t *testing.T) { + var veryLargeGroupList []string + for i := 0; i < 10000; i++ { + veryLargeGroupList = append(veryLargeGroupList, fmt.Sprintf("g%d", i)) + } + + alreadyCancelledContext, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + username string + groups []string + transforms []CELTransformation + consts *TransformationConstants + ctx context.Context + + wantUsername string + wantGroups []string + wantAuthRejected bool + wantAuthRejectedMessage string + wantCompileErr string + wantEvaluationErr string + }{ + { + name: "empty transforms list does not change the identity and allows auth", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{}, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "simple transforms which do not change the identity and allows auth", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username`}, + &GroupsTransformation{Expression: `groups`}, + &AllowAuthenticationPolicy{Expression: `true`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "transformations run in the order that they are given and accumulate results", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `"a:" + username`}, + &UsernameTransformation{Expression: `"b:" + username`}, + &GroupsTransformation{Expression: `groups.map(g, "a:" + g)`}, + &GroupsTransformation{Expression: `groups.map(g, "b:" + g)`}, + }, + wantUsername: "b:a:ryan", + wantGroups: []string{"b:a:admins", "b:a:developers", "b:a:other"}, + }, + { + name: "policies which return false cause the pipeline to stop running and return a rejected auth result", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `"a:" + username`}, + &AllowAuthenticationPolicy{Expression: `true`, RejectedAuthenticationMessage: `Everyone is allowed`}, + &GroupsTransformation{Expression: `groups.map(g, "a:" + g)`}, + &AllowAuthenticationPolicy{Expression: `username == "admin"`, RejectedAuthenticationMessage: `Only the username "admin" is allowed`}, + &GroupsTransformation{Expression: `groups.map(g, "b:" + g)`}, // does not get evaluated + }, + wantUsername: "a:ryan", + wantGroups: []string{"a:admins", "a:developers", "a:other"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only the username "admin" is allowed`, + }, + { + name: "policies without a RejectedAuthenticationMessage get a default message", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `username == "admin"`, RejectedAuthenticationMessage: ""}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `authentication was rejected by a configured policy`, + }, + { + name: "any transformations can use the username and group variables", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `groups[0] == "admins" && username == "ryan"`}, + &GroupsTransformation{Expression: `groups + [username]`}, + &UsernameTransformation{Expression: `groups[2]`}, // changes the username to "other" + &GroupsTransformation{Expression: `groups + [username + "2"]`}, // by the time this expression runs, the username was already changed to "other" + }, + wantUsername: "other", + wantGroups: []string{"admins", "developers", "other", "other2", "ryan"}, + }, + { + name: "any transformation can use the provided constants as variables", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + consts: &TransformationConstants{ + StringConstants: map[string]string{ + "x": "abc", + "y": "def", + }, + StringListConstants: map[string][]string{ + "x": {"uvw", "xyz"}, + "y": {"123", "456"}, + }, + }, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `strConst.x + strListConst.x[0]`}, + &GroupsTransformation{Expression: `[strConst.x, strConst.y, strListConst.x[1], strListConst.y[0]]`}, + &AllowAuthenticationPolicy{Expression: `strConst.x == "abc"`}, + }, + wantUsername: "abcuvw", + wantGroups: []string{"123", "abc", "def", "xyz"}, + }, + { + name: "the CEL string extensions are enabled for use in the expressions", + username: " ryan ", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.map(g, g.replace("mins", "ministrators"))`}, + &UsernameTransformation{Expression: `username.upperAscii()`}, + &AllowAuthenticationPolicy{Expression: `(username.lowerAscii()).trim() == "ryan"`, RejectedAuthenticationMessage: `Silly example`}, + &UsernameTransformation{Expression: `username.trim()`}, + }, + wantUsername: "RYAN", + wantGroups: []string{"administrators", "developers", "other"}, + }, + { + name: "UTC is the default time zone for time operations", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `string(timestamp("2023-01-16T10:00:20.021-08:00").getHours())`}, + }, + // Without the compiler option cel.DefaultUTCTimeZone, this result would be 10. + // With the option, this result is the original hour from the timestamp string (10), plus the effect + // of the timezone (8), to move the hour into the UTC time zone. + wantUsername: "18", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "the default UTC time zone for time operations can be overridden by passing the time zone as an argument to the operation", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `string(timestamp("2023-01-16T10:00:20.021-08:00").getHours("US/Mountain"))`}, + }, + // This is the hour of the timestamp in Mountain time, which is one time zone over from Pacific (-08:00), + // hence it is one larger than the original "10" from the timestamp string. + wantUsername: "11", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "quick expressions are finished by CEL before CEL even looks at the cancel context", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `["one group"]`}, + }, + ctx: alreadyCancelledContext, + wantUsername: "ryan", + wantGroups: []string{"one group"}, + }, + + // + // Unit tests to demonstrate practical examples of useful CEL expressions. + // + { + name: "can prefix username and all groups", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `"username_prefix:" + username`}, + &GroupsTransformation{Expression: `groups.map(g, "group_prefix:" + g)`}, + }, + wantUsername: "username_prefix:ryan", + wantGroups: []string{"group_prefix:admins", "group_prefix:developers", "group_prefix:other"}, + }, + { + name: "can suffix username and all groups", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username + ":username_suffix"`}, + &GroupsTransformation{Expression: `groups.map(g, g + ":group_suffix")`}, + }, + wantUsername: "ryan:username_suffix", + wantGroups: []string{"admins:group_suffix", "developers:group_suffix", "other:group_suffix"}, + }, + { + name: "can change case of username and all groups", + username: "rYan 🚀", + groups: []string{"aDmins", "dEvelopers", "oTher"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username.lowerAscii()`}, + &GroupsTransformation{Expression: `groups.map(g, g.upperAscii())`}, + }, + wantUsername: "ryan 🚀", + wantGroups: []string{"ADMINS", "DEVELOPERS", "OTHER"}, + }, + { + name: "can replace whitespace", + username: " r\ty a n \n", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username.replace(" ", "").replace("\n", "").replace("\t", "")`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "can use regex on strings: when the regex matches", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username.matches("^r[abcy].n$") ? "ryan-modified" : username`}, + }, + wantUsername: "ryan-modified", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "can use regex on strings: when the regex does not match", + username: "olive", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username.matches("^r[abcy].n$") ? "ryan-modified" : username`}, + }, + wantUsername: "olive", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "can filter groups based on an allow list", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(g, g in ["admins", "developers"])`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers"}, + }, + { + name: "can filter groups based on an allow list provided as a const", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + consts: &TransformationConstants{ + StringListConstants: map[string][]string{"allowedGroups": {"admins", "developers"}}, + }, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(g, g in strListConst.allowedGroups)`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers"}, + }, + { + name: "can filter groups based on a disallow list", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(g, !(g in ["admins", "developers"]))`}, + }, + wantUsername: "ryan", + wantGroups: []string{"other"}, + }, + { + name: "can filter groups based on a disallowed prefixes", + username: "ryan", + groups: []string{"disallowed1:admins", "disallowed2:developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(group, !(["disallowed1:", "disallowed2:"].exists(prefix, group.startsWith(prefix))))`}, + }, + wantUsername: "ryan", + wantGroups: []string{"other"}, + }, + { + name: "can filter groups based on a disallowed prefixes provided as a const", + username: "ryan", + groups: []string{"disallowed1:admins", "disallowed2:developers", "other"}, + consts: &TransformationConstants{ + StringListConstants: map[string][]string{"disallowedPrefixes": {"disallowed1:", "disallowed2:"}}, + }, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(group, !(strListConst.disallowedPrefixes.exists(prefix, group.startsWith(prefix))))`}, + }, + wantUsername: "ryan", + wantGroups: []string{"other"}, + }, + { + name: "can add a group", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups + ["new-group"]`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "new-group", "other"}, + }, + { + name: "a nil passed as groups will be converted to an empty list", + username: "ryan", + groups: nil, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups`}, + }, + wantUsername: "ryan", + wantGroups: []string{}, + }, + { + name: "a nil passed as groups will be converted to an empty list and can be used with CEL operators", + username: "ryan", + groups: nil, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups == [] ? ["the-groups-list-was-an-empty-list"] : ["the-groups-list-was-not-an-empty-list"]`}, + }, + wantUsername: "ryan", + wantGroups: []string{"the-groups-list-was-an-empty-list"}, + }, + { + name: "an empty list of groups is allowed", + username: "ryan", + groups: []string{}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups`}, + }, + wantUsername: "ryan", + wantGroups: []string{}, + }, + { + name: "can add a group from a const", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + consts: &TransformationConstants{ + StringConstants: map[string]string{"groupToAlwaysAdd": "new-group"}, + }, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups + [strConst.groupToAlwaysAdd]`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "new-group", "other"}, + }, + { + name: "can add a group but only if they already belong to another group - when the user does belong to that other group", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `"other" in groups ? groups + ["new-group"] : groups`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "new-group", "other"}, + }, + { + name: "can add a group but only if they already belong to another group - when the user does NOT belong to that other group", + username: "ryan", + groups: []string{"admins", "developers"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `"other" in groups ? groups + ["new-group"] : groups`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers"}, + }, + { + name: "can rename a group", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.map(g, g == "other" ? "other-renamed" : g)`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other-renamed"}, + }, + { + name: "can reject auth based on belonging to one group - when the user meets the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other", "super-admins"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `"super-admins" in groups`, RejectedAuthenticationMessage: `Only users who belong to the "super-admins" group are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other", "super-admins"}, + }, + { + name: "can reject auth based on belonging to one group - when the user does NOT meet the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `"super-admins" in groups`, RejectedAuthenticationMessage: `Only users who belong to the "super-admins" group are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only users who belong to the "super-admins" group are allowed`, + }, + { + name: "can reject auth unless the user belongs to any one of the groups in a list - when the user meets the criteria", + username: "ryan", + groups: []string{"admins", "developers", "foobar", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `groups.exists(g, g in ["foobar", "foobaz", "foobat"])`, RejectedAuthenticationMessage: `Only users who belong to any of the groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "foobar", "other"}, + }, + { + name: "can reject auth unless the user belongs to any one of the groups in a list - when the user does NOT meet the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `groups.exists(g, g in ["foobar", "foobaz", "foobat"])`, RejectedAuthenticationMessage: `Only users who belong to any of the groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only users who belong to any of the groups in a list are allowed`, + }, + { + name: "can reject auth unless the user belongs to all of the groups in a list - when the user meets the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other", "foobar", "foobaz", "foobat"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `["foobar", "foobaz", "foobat"].all(g, g in groups)`, RejectedAuthenticationMessage: `Only users who belong to all groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "foobar", "foobat", "foobaz", "other"}, + }, + { + name: "can reject auth unless the user belongs to all of the groups in a list - when the user does NOT meet the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other", "foobaz", "foobat"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `["foobar", "foobaz", "foobat"].all(g, g in groups)`, RejectedAuthenticationMessage: `Only users who belong to all groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other", "foobaz", "foobat"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only users who belong to all groups in a list are allowed`, + }, + { + name: "can reject auth if the user belongs to any groups in a disallowed groups list - when the user meets the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `!groups.exists(g, g in ["foobar", "foobaz"])`, RejectedAuthenticationMessage: `Only users who do not belong to any of the groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "can reject auth if the user belongs to any groups in a disallowed groups list - when the user does NOT meet the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other", "foobaz"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `!groups.exists(g, g in ["foobar", "foobaz"])`, RejectedAuthenticationMessage: `Only users who do not belong to any of the groups in a list are allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other", "foobaz"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only users who do not belong to any of the groups in a list are allowed`, + }, + { + name: "can reject auth unless the username is in an allowed users list - when the user meets the criteria", + username: "foobaz", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `username in ["foobar", "foobaz"]`, RejectedAuthenticationMessage: `Only certain usernames allowed`}, + }, + wantUsername: "foobaz", + wantGroups: []string{"admins", "developers", "other"}, + }, + { + name: "can reject auth unless the username is in an allowed users list - when the user does NOT meet the criteria", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `username in ["foobar", "foobaz"]`, RejectedAuthenticationMessage: `Only certain usernames allowed`}, + }, + wantUsername: "ryan", + wantGroups: []string{"admins", "developers", "other"}, + wantAuthRejected: true, + wantAuthRejectedMessage: `Only certain usernames allowed`, + }, + + // + // Error cases + // + { + name: "username transformation returns an empty string as the new username", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `""`}, + }, + wantEvaluationErr: "identity transformation returned an empty username, which is not allowed", + }, + { + name: "username transformation returns a string containing only whitespace as the new username", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `" \n \t "`}, + }, + wantEvaluationErr: "identity transformation returned an empty username, which is not allowed", + }, + { + name: "username transformation compiles to return null, which is not a string so it has the wrong type", + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `null`}, + }, + wantCompileErr: `CEL expression should return type "string" but returns type "null_type"`, + }, + { + name: "groups transformation compiles to return null, which is not a string so it has the wrong type", + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `null`}, + }, + wantCompileErr: `CEL expression should return type "list(string)" but returns type "null_type"`, + }, + { + name: "policy transformation compiles to return null, which is not a string so it has the wrong type", + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `null`}, + }, + wantCompileErr: `CEL expression should return type "bool" but returns type "null_type"`, + }, + { + name: "username transformation has empty expression", + transforms: []CELTransformation{ + &UsernameTransformation{Expression: ``}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "groups transformation has empty expression", + transforms: []CELTransformation{ + &GroupsTransformation{Expression: ``}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "policy transformation has empty expression", + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: ``}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "username transformation has expression which contains only whitespace", + transforms: []CELTransformation{ + &UsernameTransformation{Expression: " \n\t "}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "groups transformation has expression which contains only whitespace", + transforms: []CELTransformation{ + &GroupsTransformation{Expression: " \n\t "}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "policy transformation has expression which contains only whitespace", + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: " \n\t "}, + }, + wantCompileErr: `cannot compile empty CEL expression`, + }, + { + name: "slow username transformation expressions are canceled by the cancel context after partial evaluation", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`}, + }, + ctx: alreadyCancelledContext, + wantEvaluationErr: `identity transformation at index 0: operation interrupted`, + }, + { + name: "slow groups transformation expressions are canceled by the cancel context after partial evaluation", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.filter(x, groups.all(x, true))`}, + }, + ctx: alreadyCancelledContext, + wantEvaluationErr: `identity transformation at index 0: operation interrupted`, + }, + { + name: "slow policy expressions are canceled by the cancel context after partial evaluation", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: "username"}, + &AllowAuthenticationPolicy{Expression: `groups.all(x, groups.all(x, true))`}, // this is the slow one + }, + ctx: alreadyCancelledContext, + wantEvaluationErr: `identity transformation at index 1: operation interrupted`, + }, + { + name: "slow transformation expressions are canceled and the rest of the expressions do not run", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username`}, // quick expressions are allowed to run even though the context is cancelled + &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`}, + &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`}, + &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`}, + }, + ctx: alreadyCancelledContext, + wantEvaluationErr: `identity transformation at index 1: operation interrupted`, + }, + { + name: "slow username transformation expressions are canceled after a maximum allowed duration", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion. + &UsernameTransformation{Expression: `groups.filter(x, groups.all(x, true))[0]`}, + }, + wantEvaluationErr: `identity transformation at index 0: operation interrupted`, + }, + { + name: "slow groups transformation expressions are canceled after a maximum allowed duration", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion. + &GroupsTransformation{Expression: `groups.filter(x, groups.all(x, true))`}, + }, + wantEvaluationErr: `identity transformation at index 0: operation interrupted`, + }, + { + name: "slow policy transformation expressions are canceled after a maximum allowed duration", + username: "ryan", + groups: veryLargeGroupList, + transforms: []CELTransformation{ + // On my laptop, evaluating this expression would take ~20 seconds if we allowed it to evaluate to completion. + &AllowAuthenticationPolicy{Expression: `groups.all(x, groups.all(x, true))`}, + }, + wantEvaluationErr: `identity transformation at index 0: operation interrupted`, + }, + { + name: "compile errors are returned by the compile step for a username transform", + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `foobar.junk()`}, + }, + wantCompileErr: here.Doc(` + CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '') + | foobar.junk() + | ^ + ERROR: :1:12: undeclared reference to 'junk' (in container '') + | foobar.junk() + | ...........^`, + ), + }, + { + name: "compile errors are returned by the compile step for a groups transform", + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `foobar.junk()`}, + }, + wantCompileErr: here.Doc(` + CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '') + | foobar.junk() + | ^ + ERROR: :1:12: undeclared reference to 'junk' (in container '') + | foobar.junk() + | ...........^`, + ), + }, + { + name: "compile errors are returned by the compile step for a policy", + transforms: []CELTransformation{ + &AllowAuthenticationPolicy{Expression: `foobar.junk()`}, + }, + wantCompileErr: here.Doc(` + CEL expression compile error: ERROR: :1:1: undeclared reference to 'foobar' (in container '') + | foobar.junk() + | ^ + ERROR: :1:12: undeclared reference to 'junk' (in container '') + | foobar.junk() + | ...........^`, + ), + }, + { + name: "evaluation errors stop the pipeline and return an error", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: "username"}, + &AllowAuthenticationPolicy{Expression: `1 / 0 == 7`}, + }, + wantEvaluationErr: `identity transformation at index 1: division by zero`, + }, + { + name: "HomogeneousAggregateLiterals compiler setting is enabled to help the user avoid type mistakes in expressions", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.all(g, g in ["admins", 1])`}, + }, + wantCompileErr: here.Doc(` + CEL expression compile error: ERROR: :1:31: expected type 'string' but found 'int' + | groups.all(g, g in ["admins", 1]) + | ..............................^`, + ), + }, + { + name: "when an expression's type cannot be determined at compile time, e.g. due to the use of dynamic types", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `groups.map(g, {"admins": dyn(1), "developers":"a"}[g])`}, + }, + wantCompileErr: `CEL expression should return type "list(string)" but returns type "list(dyn)"`, + }, + { + name: "using string constants which were not were provided", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `strConst.x`}, + }, + wantEvaluationErr: `identity transformation at index 0: no such key: x`, + }, + { + name: "using string list constants which were not were provided", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + transforms: []CELTransformation{ + &GroupsTransformation{Expression: `strListConst.x`}, + }, + wantEvaluationErr: `identity transformation at index 0: no such key: x`, + }, + { + name: "using an illegal name for a string constant", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + consts: &TransformationConstants{StringConstants: map[string]string{" illegal": "a"}}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username`}, + }, + wantCompileErr: `" illegal" is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)`, + }, + { + name: "using an illegal name for a stringList constant", + username: "ryan", + groups: []string{"admins", "developers", "other"}, + consts: &TransformationConstants{StringListConstants: map[string][]string{" illegal": {"a"}}}, + transforms: []CELTransformation{ + &UsernameTransformation{Expression: `username`}, + }, + wantCompileErr: `" illegal" is an invalid const variable name (must match [_a-zA-Z][_a-zA-Z0-9]*)`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + transformer, err := NewCELTransformer(100 * time.Millisecond) + require.NoError(t, err) + + pipeline := idtransform.NewTransformationPipeline() + expectedPipelineSource := []interface{}{} + + for _, transform := range tt.transforms { + compiledTransform, err := transformer.CompileTransformation(transform, tt.consts) + if tt.wantCompileErr != "" { + require.EqualError(t, err, tt.wantCompileErr) + return // the rest of the test doesn't make sense when there was a compile error + } + require.NoError(t, err, "got an unexpected compile error") + pipeline.AppendTransformation(compiledTransform) + + expectedTransformSource := &CELTransformationSource{ + Expr: transform, + Consts: tt.consts, + } + if expectedTransformSource.Consts == nil { + expectedTransformSource.Consts = &TransformationConstants{} + } + expectedPipelineSource = append(expectedPipelineSource, expectedTransformSource) + } + + ctx := context.Background() + if tt.ctx != nil { + ctx = tt.ctx + } + + result, err := pipeline.Evaluate(ctx, tt.username, tt.groups) + if tt.wantEvaluationErr != "" { + require.EqualError(t, err, tt.wantEvaluationErr) + return // the rest of the test doesn't make sense when there was an evaluation error + } + require.NoError(t, err, "got an unexpected evaluation error") + + require.Equal(t, tt.wantUsername, result.Username) + require.Equal(t, tt.wantGroups, result.Groups) + require.Equal(t, !tt.wantAuthRejected, result.AuthenticationAllowed, "AuthenticationAllowed had unexpected value") + require.Equal(t, tt.wantAuthRejectedMessage, result.RejectedAuthenticationMessage) + + require.Equal(t, expectedPipelineSource, pipeline.Source()) + }) + } +} + +func TestTypicalPerformanceAndThreadSafety(t *testing.T) { + t.Parallel() + + transformer, err := NewCELTransformer(5 * time.Second) // CI workers can be slow, so allow slow transforms + require.NoError(t, err) + + pipeline := idtransform.NewTransformationPipeline() + + var compiledTransform idtransform.IdentityTransformation + compiledTransform, err = transformer.CompileTransformation(&UsernameTransformation{Expression: `"username_prefix:" + username`}, nil) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform) + compiledTransform, err = transformer.CompileTransformation(&GroupsTransformation{Expression: `groups.map(g, "group_prefix:" + g)`}, nil) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform) + compiledTransform, err = transformer.CompileTransformation(&AllowAuthenticationPolicy{Expression: `username == "username_prefix:ryan"`}, nil) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform) + + var groups []string + var wantGroups []string + for i := 0; i < 100; i++ { + groups = append(groups, fmt.Sprintf("g%d", i)) + wantGroups = append(wantGroups, fmt.Sprintf("group_prefix:g%d", i)) + } + sort.Strings(wantGroups) + + // Before looking at performance, check that the behavior of the function is correct. + result, err := pipeline.Evaluate(context.Background(), "ryan", groups) + require.NoError(t, err) + require.Equal(t, "username_prefix:ryan", result.Username) + require.Equal(t, wantGroups, result.Groups) + require.True(t, result.AuthenticationAllowed) + require.Empty(t, result.RejectedAuthenticationMessage) + + // This loop is meant to give a sense of typical runtime of CEL expressions which transforms a username + // and 100 group names. It is not meant to be a pass/fail test or scientific benchmark test. + iterations := 1000 + start := time.Now() + for i := 0; i < iterations; i++ { + _, _ = pipeline.Evaluate(context.Background(), "ryan", groups) + } + elapsed := time.Since(start) + t.Logf("TestTypicalPerformanceAndThreadSafety %d iterations of Evaluate took %s; average runtime %s", iterations, elapsed, elapsed/time.Duration(iterations)) + // On my laptop this prints: TestTypicalPerformanceAndThreadSafety 1000 iterations of Evaluate took 257.981421ms; average runtime 257.981µs + + // Now use the transformations pipeline from different goroutines at the same time. Hopefully the race detector + // will complain if this is not thread safe in some way. Use the pipeline enough that it will be very likely that + // there will be several parallel invocations of the Evaluate function. Every invocation should also yield the + // exact same result, since they are all using the same inputs. This assumes that the unit tests are run using + // the race detector. + var wg sync.WaitGroup + numGoroutines := runtime.NumCPU() / 2 + t.Logf("Running tight loops in %d simultaneous goroutines", numGoroutines) + for i := 0; i < numGoroutines; i++ { + wg.Add(1) // increment WaitGroup counter for each goroutine + go func() { + defer wg.Done() // decrement WaitGroup counter when this goroutine finishes + for j := 0; j < iterations*2; j++ { + localResult, localErr := pipeline.Evaluate(context.Background(), "ryan", groups) + require.NoError(t, localErr) + require.Equal(t, "username_prefix:ryan", localResult.Username) + require.Equal(t, wantGroups, localResult.Groups) + require.True(t, localResult.AuthenticationAllowed) + require.Empty(t, localResult.RejectedAuthenticationMessage) + } + }() + } + wg.Wait() // wait for the counter to reach zero, indicating the all goroutines are finished +} diff --git a/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go b/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go index 42dbf7d5..e75c7a25 100644 --- a/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go +++ b/internal/certauthority/dynamiccertauthority/dynamiccertauthority.go @@ -11,7 +11,7 @@ import ( "k8s.io/apiserver/pkg/server/dynamiccertificates" "go.pinniped.dev/internal/certauthority" - "go.pinniped.dev/internal/issuer" + "go.pinniped.dev/internal/clientcertissuer" ) // ca is a type capable of issuing certificates. @@ -21,7 +21,7 @@ type ca struct { // New creates a ClientCertIssuer, ready to issue certs whenever // the given CertKeyContentProvider has a keypair to provide. -func New(provider dynamiccertificates.CertKeyContentProvider) issuer.ClientCertIssuer { +func New(provider dynamiccertificates.CertKeyContentProvider) clientcertissuer.ClientCertIssuer { return &ca{ provider: provider, } diff --git a/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go b/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go index b33cb9dd..cd07d173 100644 --- a/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go +++ b/internal/certauthority/dynamiccertauthority/dynamiccertauthority_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/dynamiccert" - "go.pinniped.dev/internal/issuer" "go.pinniped.dev/internal/testutil" ) @@ -116,7 +116,7 @@ func TestCAIssuePEM(t *testing.T) { } } -func issuePEM(provider dynamiccert.Provider, ca issuer.ClientCertIssuer, caCrt, caKey []byte) ([]byte, []byte, error) { +func issuePEM(provider dynamiccert.Provider, ca clientcertissuer.ClientCertIssuer, caCrt, caKey []byte) ([]byte, []byte, error) { // if setting fails, look at that error if caCrt != nil || caKey != nil { if err := provider.SetCertKeyContent(caCrt, caKey); err != nil { diff --git a/internal/issuer/issuer.go b/internal/clientcertissuer/issuer.go similarity index 98% rename from internal/issuer/issuer.go rename to internal/clientcertissuer/issuer.go index 8023b7e7..1efbc3e7 100644 --- a/internal/issuer/issuer.go +++ b/internal/clientcertissuer/issuer.go @@ -1,7 +1,7 @@ // Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package issuer +package clientcertissuer import ( "fmt" diff --git a/internal/issuer/issuer_test.go b/internal/clientcertissuer/issuer_test.go similarity index 99% rename from internal/issuer/issuer_test.go rename to internal/clientcertissuer/issuer_test.go index 114e5f15..82583f20 100644 --- a/internal/issuer/issuer_test.go +++ b/internal/clientcertissuer/issuer_test.go @@ -1,7 +1,7 @@ // Copyright 2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package issuer +package clientcertissuer import ( "errors" diff --git a/internal/concierge/apiserver/apiserver.go b/internal/concierge/apiserver/apiserver.go index 5fa743b0..e1e7d074 100644 --- a/internal/concierge/apiserver/apiserver.go +++ b/internal/concierge/apiserver/apiserver.go @@ -15,8 +15,8 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" + "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/controllerinit" - "go.pinniped.dev/internal/issuer" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/pversion" "go.pinniped.dev/internal/registry/credentialrequest" @@ -30,7 +30,7 @@ type Config struct { type ExtraConfig struct { Authenticator credentialrequest.TokenCredentialRequestAuthenticator - Issuer issuer.ClientCertIssuer + Issuer clientcertissuer.ClientCertIssuer BuildControllersPostStartHook controllerinit.RunnerBuilder Scheme *runtime.Scheme NegotiatedSerializer runtime.NegotiatedSerializer diff --git a/internal/concierge/server/server.go b/internal/concierge/server/server.go index ee446c42..95fa26c5 100644 --- a/internal/concierge/server/server.go +++ b/internal/concierge/server/server.go @@ -23,6 +23,7 @@ import ( conciergeopenapi "go.pinniped.dev/generated/latest/client/concierge/openapi" "go.pinniped.dev/internal/certauthority/dynamiccertauthority" + "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/concierge/apiserver" conciergescheme "go.pinniped.dev/internal/concierge/scheme" "go.pinniped.dev/internal/config/concierge" @@ -33,7 +34,6 @@ import ( "go.pinniped.dev/internal/downward" "go.pinniped.dev/internal/dynamiccert" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/issuer" "go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/pversion" @@ -159,7 +159,7 @@ func (a *App) runServer(ctx context.Context) error { return fmt.Errorf("could not prepare controllers: %w", err) } - certIssuer := issuer.ClientCertIssuers{ + certIssuer := clientcertissuer.ClientCertIssuers{ dynamiccertauthority.New(dynamicSigningCertProvider), // attempt to use the real Kube CA if possible dynamiccertauthority.New(impersonationProxySigningCertProvider), // fallback to our internal CA if we need to } @@ -194,7 +194,7 @@ func (a *App) runServer(ctx context.Context) error { func getAggregatedAPIServerConfig( dynamicCertProvider dynamiccert.Private, authenticator credentialrequest.TokenCredentialRequestAuthenticator, - issuer issuer.ClientCertIssuer, + issuer clientcertissuer.ClientCertIssuer, buildControllers controllerinit.RunnerBuilder, apiGroupSuffix string, aggregatedAPIServerPort int64, diff --git a/internal/controller/conditionsutil/conditions_util.go b/internal/controller/conditionsutil/conditions_util.go index 2521a3a6..0bbe3603 100644 --- a/internal/controller/conditionsutil/conditions_util.go +++ b/internal/controller/conditionsutil/conditions_util.go @@ -67,11 +67,11 @@ func mergeIDPCondition(existing *[]v1.Condition, new *v1.Condition) bool { } // MergeConfigConditions merges conditions into conditionsToUpdate. If returns true if it merged any error conditions. -func MergeConfigConditions(conditions []*v1.Condition, observedGeneration int64, conditionsToUpdate *[]v1.Condition, log plog.MinLogger) bool { +func MergeConfigConditions(conditions []*v1.Condition, observedGeneration int64, conditionsToUpdate *[]v1.Condition, log plog.MinLogger, now v1.Time) bool { hadErrorCondition := false for i := range conditions { cond := conditions[i].DeepCopy() - cond.LastTransitionTime = v1.Now() + cond.LastTransitionTime = now cond.ObservedGeneration = observedGeneration if mergeConfigCondition(conditionsToUpdate, cond) { log.Info("updated condition", "type", cond.Type, "status", cond.Status, "reason", cond.Reason, "message", cond.Message) diff --git a/internal/controller/impersonatorconfig/impersonator_config_test.go b/internal/controller/impersonatorconfig/impersonator_config_test.go index 25252afc..8ec62f59 100644 --- a/internal/controller/impersonatorconfig/impersonator_config_test.go +++ b/internal/controller/impersonatorconfig/impersonator_config_test.go @@ -92,7 +92,7 @@ func TestImpersonatorConfigControllerOptions(t *testing.T) { nil, caSignerName, nil, - plog.Logr(), //nolint:staticcheck // old test with no log assertions + plog.Logr(), //nolint:staticcheck // old test with no log assertions ) credIssuerInformerFilter = observableWithInformerOption.GetFilterForInformer(credIssuerInformer) servicesInformerFilter = observableWithInformerOption.GetFilterForInformer(servicesInformer) diff --git a/internal/controller/kubecertagent/kubecertagent.go b/internal/controller/kubecertagent/kubecertagent.go index 30faa3b5..d3e41494 100644 --- a/internal/controller/kubecertagent/kubecertagent.go +++ b/internal/controller/kubecertagent/kubecertagent.go @@ -179,7 +179,7 @@ func NewAgentController( dynamicCertProvider, &clock.RealClock{}, cache.NewExpiring(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ) } diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go index f2d658f6..139592f0 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher.go @@ -26,7 +26,7 @@ import ( "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/upstreamldap" ) @@ -225,7 +225,7 @@ func (s *activeDirectoryUpstreamGenericLDAPStatus) Conditions() []metav1.Conditi // UpstreamActiveDirectoryIdentityProviderICache is a thread safe cache that holds a list of validated upstream LDAP IDP configurations. type UpstreamActiveDirectoryIdentityProviderICache interface { - SetActiveDirectoryIdentityProviders([]provider.UpstreamLDAPIdentityProviderI) + SetActiveDirectoryIdentityProviders([]upstreamprovider.UpstreamLDAPIdentityProviderI) } type activeDirectoryWatcherController struct { @@ -299,7 +299,7 @@ func (c *activeDirectoryWatcherController) Sync(ctx controllerlib.Context) error } requeue := false - validatedUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams)) + validatedUpstreams := make([]upstreamprovider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams)) for _, upstream := range actualUpstreams { valid, requestedRequeue := c.validateUpstream(ctx.Context, upstream) if valid != nil { @@ -318,7 +318,7 @@ func (c *activeDirectoryWatcherController) Sync(ctx controllerlib.Context) error return nil } -func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.ActiveDirectoryIdentityProvider) (p provider.UpstreamLDAPIdentityProviderI, requeue bool) { +func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.ActiveDirectoryIdentityProvider) (p upstreamprovider.UpstreamLDAPIdentityProviderI, requeue bool) { spec := upstream.Spec adUpstreamImpl := &activeDirectoryUpstreamGenericLDAPImpl{activeDirectoryIdentityProvider: *upstream} @@ -344,7 +344,7 @@ func (c *activeDirectoryWatcherController) validateUpstream(ctx context.Context, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){ "objectGUID": microsoftUUIDFromBinaryAttr("objectGUID"), }, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ pwdLastSetAttribute: attributeUnchangedSinceLogin(pwdLastSetAttribute), userAccountControlAttribute: validUserAccountControl, userAccountControlComputedAttribute: validComputedUserAccountControl, @@ -445,7 +445,7 @@ func getDomainFromDistinguishedName(distinguishedName string) (string, error) { } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var validUserAccountControl = func(entry *ldap.Entry, _ provider.RefreshAttributes) error { +var validUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error { userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlAttribute)) if err != nil { return err @@ -459,7 +459,7 @@ var validUserAccountControl = func(entry *ldap.Entry, _ provider.RefreshAttribut } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var validComputedUserAccountControl = func(entry *ldap.Entry, _ provider.RefreshAttributes) error { +var validComputedUserAccountControl = func(entry *ldap.Entry, _ upstreamprovider.RefreshAttributes) error { userAccountControl, err := strconv.Atoi(entry.GetAttributeValue(userAccountControlComputedAttribute)) if err != nil { return err @@ -473,8 +473,8 @@ var validComputedUserAccountControl = func(entry *ldap.Entry, _ provider.Refresh } //nolint:gochecknoglobals // this needs to be a global variable so that tests can check pointer equality -var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, provider.RefreshAttributes) error { - return func(entry *ldap.Entry, storedAttributes provider.RefreshAttributes) error { +var attributeUnchangedSinceLogin = func(attribute string) func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { + return func(entry *ldap.Entry, storedAttributes upstreamprovider.RefreshAttributes) error { prevAttributeValue := storedAttributes.AdditionalAttributes[attribute] newValues := entry.GetRawAttributeValues(attribute) diff --git a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go index bb830aa2..92500d6e 100644 --- a/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/activedirectoryupstreamwatcher/active_directory_upstream_watcher_test.go @@ -29,8 +29,9 @@ import ( "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/endpointaddr" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/mocks/mockldapconn" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/upstreamldap" ) @@ -229,7 +230,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -572,7 +573,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -642,7 +643,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: "sAMAccountName", }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -715,7 +716,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -795,7 +796,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -859,7 +860,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1010,7 +1011,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1160,7 +1161,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1232,7 +1233,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1499,7 +1500,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, GroupAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"sAMAccountName": groupSAMAccountNameWithDomainSuffix}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1559,7 +1560,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1623,7 +1624,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1687,7 +1688,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1899,7 +1900,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { GroupNameAttribute: testGroupSearchNameAttrName, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -1962,7 +1963,7 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { SkipGroupRefresh: true, }, UIDAttributeParsingOverrides: map[string]func(*ldap.Entry) (string, error){"objectGUID": microsoftUUIDFromBinaryAttr("objectGUID")}, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ "pwdLastSet": attributeUnchangedSinceLogin("pwdLastSet"), "userAccountControl": validUserAccountControl, "msDS-User-Account-Control-Computed": validComputedUserAccountControl, @@ -2009,8 +2010,8 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...) kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) - cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetActiveDirectoryIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ + cache := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() + cache.SetActiveDirectoryIdentityProviders([]upstreamprovider.UpstreamLDAPIdentityProviderI{ upstreamldap.New(upstreamldap.ProviderConfig{Name: "initial-entry"}), }) @@ -2104,8 +2105,8 @@ func TestActiveDirectoryUpstreamWatcherControllerSync(t *testing.T) { expectedRefreshAttributeChecks := copyOfExpectedValueForResultingCache.RefreshAttributeChecks actualRefreshAttributeChecks := actualConfig.RefreshAttributeChecks - copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.RefreshAttributes) error{} - actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.RefreshAttributes) error{} + copyOfExpectedValueForResultingCache.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{} + actualConfig.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{} require.Equal(t, len(expectedRefreshAttributeChecks), len(actualRefreshAttributeChecks)) for k, v := range expectedRefreshAttributeChecks { require.NotNil(t, actualRefreshAttributeChecks[k]) @@ -2354,7 +2355,7 @@ func TestValidUserAccountControl(t *testing.T) { for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { - err := validUserAccountControl(tt.entry, provider.RefreshAttributes{}) + err := validUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{}) if tt.wantErr != "" { require.Error(t, err) @@ -2415,7 +2416,7 @@ func TestValidComputedUserAccountControl(t *testing.T) { for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { - err := validComputedUserAccountControl(tt.entry, provider.RefreshAttributes{}) + err := validComputedUserAccountControl(tt.entry, upstreamprovider.RefreshAttributes{}) if tt.wantErr != "" { require.Error(t, err) @@ -2490,7 +2491,7 @@ func TestAttributeUnchangedSinceLogin(t *testing.T) { tt := test t.Run(tt.name, func(t *testing.T) { initialValRawEncoded := base64.RawURLEncoding.EncodeToString([]byte(initialVal)) - err := attributeUnchangedSinceLogin(attributeName)(tt.entry, provider.RefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}}) + err := attributeUnchangedSinceLogin(attributeName)(tt.entry, upstreamprovider.RefreshAttributes{AdditionalAttributes: map[string]string{attributeName: initialValRawEncoded}}) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) diff --git a/internal/controller/supervisorconfig/federation_domain_watcher.go b/internal/controller/supervisorconfig/federation_domain_watcher.go index 08492e17..23468115 100644 --- a/internal/controller/supervisorconfig/federation_domain_watcher.go +++ b/internal/controller/supervisorconfig/federation_domain_watcher.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorconfig @@ -7,55 +7,119 @@ 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/util/errors" - "k8s.io/client-go/util/retry" - "k8s.io/klog/v2" + "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/oidc/provider" + "go.pinniped.dev/internal/federationdomain/federationdomainproviders" + "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/plog" ) -// ProvidersSetter can be notified of all known valid providers with its SetIssuer function. +const ( + controllerName = "FederationDomainWatcherController" + + typeReady = "Ready" + typeIssuerURLValid = "IssuerURLValid" + typeOneTLSSecretPerIssuerHostname = "OneTLSSecretPerIssuerHostname" + typeIssuerIsUnique = "IssuerIsUnique" + typeIdentityProvidersFound = "IdentityProvidersFound" + typeIdentityProvidersDisplayNamesUnique = "IdentityProvidersDisplayNamesUnique" + typeIdentityProvidersAPIGroupSuffixValid = "IdentityProvidersObjectRefAPIGroupSuffixValid" + typeIdentityProvidersObjectRefKindValid = "IdentityProvidersObjectRefKindValid" + typeTransformsExpressionsValid = "TransformsExpressionsValid" + typeTransformsExamplesPassed = "TransformsExamplesPassed" + + 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" + reasonInvalidTransformsExpressions = "InvalidTransformsExpressions" + reasonTransformsExamplesFailed = "TransformsExamplesFailed" + + 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 ProvidersSetter interface { - SetProviders(federationDomains ...*provider.FederationDomainIssuer) +type FederationDomainsSetter interface { + SetFederationDomains(federationDomains ...*federationdomainproviders.FederationDomainIssuer) } type federationDomainWatcherController struct { - providerSetter ProvidersSetter - clock clock.Clock - client pinnipedclientset.Interface - federationDomainInformer configinformers.FederationDomainInformer + 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( - providerSetter ProvidersSetter, + 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", + Name: controllerName, Syncer: &federationDomainWatcherController{ - providerSetter: providerSetter, - clock: clock, - client: client, - federationDomainInformer: federationDomainInformer, + federationDomainsSetter: federationDomainsSetter, + apiGroup: fmt.Sprintf("idp.supervisor.%s", apiGroupSuffix), + clock: clock, + client: client, + federationDomainInformer: federationDomainInformer, + oidcIdentityProviderInformer: oidcIdentityProviderInformer, + ldapIdentityProviderInformer: ldapIdentityProviderInformer, + activeDirectoryIdentityProviderInformer: activeDirectoryIdentityProviderInformer, + allowedKinds: allowedKinds, }, }, withInformer( @@ -63,6 +127,27 @@ func NewFederationDomainWatcherController( 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{}, + ), ) } @@ -73,13 +158,766 @@ func (c *federationDomainWatcherController) Sync(ctx controllerlib.Context) erro 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(ctx.Context, 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( + ctx context.Context, + federationDomains []*configv1alpha1.FederationDomain, +) ([]*federationdomainproviders.FederationDomainIssuer, map[*configv1alpha1.FederationDomain][]*metav1.Condition, error) { + federationDomainIssuers := make([]*federationdomainproviders.FederationDomainIssuer, 0) + fdToConditionsMap := map[*configv1alpha1.FederationDomain][]*metav1.Condition{} + crossDomainConfigValidator := newCrossFederationDomainConfigValidator(federationDomains) + + for _, federationDomain := range federationDomains { + conditions := make([]*metav1.Condition, 0) + + conditions = crossDomainConfigValidator.Validate(federationDomain, conditions) + + federationDomainIssuer, conditions, err := c.makeFederationDomainIssuer(ctx, 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( + ctx context.Context, + federationDomain *configv1alpha1.FederationDomain, + conditions []*metav1.Condition, +) (*federationdomainproviders.FederationDomainIssuer, []*metav1.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(ctx, federationDomain, conditions) + if err != nil { + return nil, nil, err + } + } + + return federationDomainIssuer, conditions, nil +} + +func (c *federationDomainWatcherController) makeLegacyFederationDomainIssuer( + federationDomain *configv1alpha1.FederationDomain, + conditions []*metav1.Condition, +) (*federationdomainproviders.FederationDomainIssuer, []*metav1.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, &metav1.Condition{ + Type: typeIdentityProvidersFound, + Status: metav1.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, &metav1.Condition{ + Type: typeIdentityProvidersFound, + Status: metav1.ConditionFalse, + Reason: reasonIdentityProviderNotSpecified, // vs LegacyConfigurationIdentityProviderNotFound as this is more specific + Message: fmt.Sprintf("no resources were specified by .spec.identityProviders[].objectRef "+ + "and %d 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, &metav1.Condition{ + Type: typeIdentityProvidersFound, + Status: metav1.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 legacy backwards compatibility mode. + federationDomainIssuer, err := federationdomainproviders.NewFederationDomainIssuerWithDefaultIDP(federationDomain.Spec.Issuer, defaultFederationDomainIdentityProvider) + conditions = appendIssuerURLValidCondition(err, conditions) + + // These conditions can only have errors when the list of IDPs is explicitly configured, + // and in this case there are no IDPs explicitly configured, so set these conditions all to have no errors. + conditions = appendIdentityProviderDuplicateDisplayNamesCondition(sets.Set[string]{}, conditions) + conditions = appendIdentityProviderObjectRefAPIGroupSuffixCondition(c.apiGroup, []string{}, conditions) + conditions = appendIdentityProviderObjectRefKindCondition(c.sortedAllowedKinds(), []string{}, conditions) + conditions = appendTransformsExpressionsValidCondition([]string{}, conditions) + conditions = appendTransformsExamplesPassedCondition([]string{}, conditions) + + return federationDomainIssuer, conditions, nil +} + +//nolint:funlen +func (c *federationDomainWatcherController) makeFederationDomainIssuerWithExplicitIDPs( + ctx context.Context, + federationDomain *configv1alpha1.FederationDomain, + conditions []*metav1.Condition, +) (*federationdomainproviders.FederationDomainIssuer, []*metav1.Condition, error) { + federationDomainIdentityProviders := []*federationdomainproviders.FederationDomainIdentityProvider{} + idpNotFoundIndices := []int{} + displayNames := sets.Set[string]{} + duplicateDisplayNames := sets.Set[string]{} + badAPIGroupNames := []string{} + badKinds := []string{} + validationErrorMessages := &transformsValidationErrorMessages{} + + for index, idp := range federationDomain.Spec.IdentityProviders { + idpIsValid := true + + // 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) + idpIsValid = false + } + 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 + } + + // When the apiGroup and kind are valid, try to find the IDP CR. + 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) + idpIsValid = false + } + + var err error + var pipeline *idtransform.TransformationPipeline + var allExamplesPassed bool + pipeline, allExamplesPassed, err = c.makeTransformationPipelineAndEvaluateExamplesForIdentityProvider( + ctx, idp, index, validationErrorMessages) + if err != nil { + return nil, nil, err + } + if !allExamplesPassed { + idpIsValid = false + } + + if !idpIsValid { + // Something about the IDP was not valid. Don't add it. + continue + } + + // For a 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, + }) + } + + // 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 = appendIdentityProvidersFoundCondition(idpNotFoundIndices, federationDomain.Spec.IdentityProviders, conditions) + conditions = appendIdentityProviderDuplicateDisplayNamesCondition(duplicateDisplayNames, conditions) + conditions = appendIdentityProviderObjectRefAPIGroupSuffixCondition(c.apiGroup, badAPIGroupNames, conditions) + conditions = appendIdentityProviderObjectRefKindCondition(c.sortedAllowedKinds(), badKinds, conditions) + + conditions = appendTransformsExpressionsValidCondition(validationErrorMessages.errorsForExpressions, conditions) + conditions = appendTransformsExamplesPassedCondition(validationErrorMessages.errorsForExamples, 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) makeTransformationPipelineAndEvaluateExamplesForIdentityProvider( + ctx context.Context, + idp configv1alpha1.FederationDomainIdentityProvider, + idpIndex int, + validationErrorMessages *transformsValidationErrorMessages, +) (*idtransform.TransformationPipeline, bool, error) { + consts, err := c.makeTransformsConstantsForIdentityProvider(idp) + if err != nil { + return nil, false, err + } + + pipeline, errorsForExpressions, err := c.makeTransformationPipelineForIdentityProvider(idp, idpIndex, consts) + if err != nil { + return nil, false, err + } + if len(errorsForExpressions) > 0 { + validationErrorMessages.errorsForExpressions = append(validationErrorMessages.errorsForExpressions, errorsForExpressions) + } + + allExamplesPassed, errorsForExamples := c.evaluateExamplesForIdentityProvider(ctx, idp, idpIndex, pipeline) + if len(errorsForExamples) > 0 { + validationErrorMessages.errorsForExamples = append(validationErrorMessages.errorsForExamples, errorsForExamples) + } + + return pipeline, allExamplesPassed, nil +} + +func (c *federationDomainWatcherController) makeTransformsConstantsForIdentityProvider( + idp configv1alpha1.FederationDomainIdentityProvider, +) (*celtransformer.TransformationConstants, error) { + consts := &celtransformer.TransformationConstants{ + StringConstants: map[string]string{}, + StringListConstants: map[string][]string{}, + } + constNames := 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, + // and validates that the names are unique within the list. + 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, fmt.Errorf("one of spec.identityProvider[].transforms.constants[].type is invalid: %q", constant.Type) + } + } + + return consts, nil +} + +func (c *federationDomainWatcherController) makeTransformationPipelineForIdentityProvider( + idp configv1alpha1.FederationDomainIdentityProvider, + idpIndex int, + consts *celtransformer.TransformationConstants, +) (*idtransform.TransformationPipeline, string, error) { + pipeline := idtransform.NewTransformationPipeline() + expressionsCompileErrors := []string{} + + // Compile all the expressions and add them to the pipeline. + for exprIndex, 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, "", fmt.Errorf("one of spec.identityProvider[].transforms.expressions[].type is invalid: %q", expr.Type) + } + + compiledTransform, err := c.celTransformer.CompileTransformation(rawTransform, consts) + if err != nil { + expressionsCompileErrors = append(expressionsCompileErrors, + fmt.Sprintf("spec.identityProvider[%d].transforms.expressions[%d].expression was invalid:\n%s", + idpIndex, exprIndex, err.Error())) + } + + pipeline.AppendTransformation(compiledTransform) + } + + if len(expressionsCompileErrors) > 0 { + // One or more of the expressions did not compile, so we don't have a useful pipeline to return. + // Return the validation messages. + return nil, strings.Join(expressionsCompileErrors, "\n\n"), nil + } + + return pipeline, "", nil +} + +func (c *federationDomainWatcherController) evaluateExamplesForIdentityProvider( + ctx context.Context, + idp configv1alpha1.FederationDomainIdentityProvider, + idpIndex int, + pipeline *idtransform.TransformationPipeline, +) (bool, string) { + const errorFmt = ".spec.identityProviders[%d].transforms.examples[%d] example failed:\nexpected: %s\nactual: %s" + examplesErrors := []string{} + + if pipeline == nil { + // Unable to evaluate the conditions where the pipeline of expressions was invalid. + return false, fmt.Sprintf( + "unable to check if the examples specified by .spec.identityProviders[%d].transforms.examples[] had errors because an expression was invalid", + idpIndex) + } + + // Run all the provided transform examples. If any fail, put errors on the FederationDomain status. + for exIndex, e := range idp.Transforms.Examples { + result, err := pipeline.Evaluate(ctx, e.Username, e.Groups) + if err != nil { + examplesErrors = append(examplesErrors, fmt.Sprintf(errorFmt, idpIndex, exIndex, + "no transformation errors", + fmt.Sprintf("transformations resulted in an unexpected error %q", err.Error()))) + continue + } + resultWasAuthRejected := !result.AuthenticationAllowed + + if e.Expects.Rejected && !resultWasAuthRejected { + examplesErrors = append(examplesErrors, + fmt.Sprintf(errorFmt, idpIndex, exIndex, "authentication to be rejected", "authentication was not rejected")) + continue + } + + if !e.Expects.Rejected && resultWasAuthRejected { + examplesErrors = append(examplesErrors, fmt.Sprintf(errorFmt, idpIndex, exIndex, + "authentication not to be rejected", + fmt.Sprintf("authentication was rejected with message %q", result.RejectedAuthenticationMessage))) + continue + } + + expectedRejectionMessage := e.Expects.Message + if len(expectedRejectionMessage) == 0 { + expectedRejectionMessage = celtransformer.DefaultPolicyRejectedAuthMessage + } + if e.Expects.Rejected && resultWasAuthRejected && expectedRejectionMessage != result.RejectedAuthenticationMessage { + examplesErrors = append(examplesErrors, fmt.Sprintf(errorFmt, idpIndex, exIndex, + fmt.Sprintf("authentication rejection message %q", expectedRejectionMessage), + fmt.Sprintf("authentication rejection message %q", result.RejectedAuthenticationMessage))) + continue + } + + 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. + if e.Expects.Username != result.Username { + examplesErrors = append(examplesErrors, fmt.Sprintf(errorFmt, idpIndex, exIndex, + fmt.Sprintf("username %q", e.Expects.Username), + fmt.Sprintf("username %q", result.Username))) + } + expectedGroups := e.Expects.Groups + if expectedGroups == nil { + expectedGroups = []string{} + } + if !stringSetsEqual(expectedGroups, result.Groups) { + examplesErrors = append(examplesErrors, fmt.Sprintf(errorFmt, idpIndex, exIndex, + fmt.Sprintf("groups [%s]", strings.Join(sortAndQuote(expectedGroups), ", ")), + fmt.Sprintf("groups [%s]", strings.Join(sortAndQuote(result.Groups), ", ")))) + } + } + } + + if len(examplesErrors) > 0 { + return false, strings.Join(examplesErrors, "\n\n") + } + + return true, "" +} + +func appendIdentityProviderObjectRefKindCondition(expectedKinds []string, badSuffixNames []string, conditions []*metav1.Condition) []*metav1.Condition { + if len(badSuffixNames) > 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersObjectRefKindValid, + Status: metav1.ConditionFalse, + Reason: reasonKindUnrecognized, + Message: fmt.Sprintf("some 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, &metav1.Condition{ + Type: typeIdentityProvidersObjectRefKindValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the kinds specified by .spec.identityProviders[].objectRef.kind are recognized", + }) + } + return conditions +} + +func appendIdentityProvidersFoundCondition( + idpNotFoundIndices []int, + federationDomainIdentityProviders []configv1alpha1.FederationDomainIdentityProvider, + conditions []*metav1.Condition, +) []*metav1.Condition { + if len(idpNotFoundIndices) != 0 { + messages := []string{} + for _, idpNotFoundIndex := range idpNotFoundIndices { + messages = append(messages, fmt.Sprintf("cannot find resource specified by .spec.identityProviders[%d].objectRef (with name %q)", + idpNotFoundIndex, federationDomainIdentityProviders[idpNotFoundIndex].ObjectRef.Name)) + } + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersFound, + Status: metav1.ConditionFalse, + Reason: reasonIdentityProvidersObjectRefsNotFound, + Message: strings.Join(messages, "\n\n"), + }) + } else if len(federationDomainIdentityProviders) != 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersFound, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the resources specified by .spec.identityProviders[].objectRef were found", + }) + } + return conditions +} + +func appendIdentityProviderObjectRefAPIGroupSuffixCondition(expectedSuffixName string, badSuffixNames []string, conditions []*metav1.Condition) []*metav1.Condition { + if len(badSuffixNames) > 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersAPIGroupSuffixValid, + Status: metav1.ConditionFalse, + Reason: reasonAPIGroupNameUnrecognized, + Message: fmt.Sprintf("some API groups specified by .spec.identityProviders[].objectRef.apiGroup are not recognized (should be %q): %s", + expectedSuffixName, strings.Join(sortAndQuote(badSuffixNames), ", ")), + }) + } else { + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersAPIGroupSuffixValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the API groups specified by .spec.identityProviders[].objectRef.apiGroup are recognized", + }) + } + return conditions +} + +func appendTransformsExpressionsValidCondition(messages []string, conditions []*metav1.Condition) []*metav1.Condition { + if len(messages) > 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeTransformsExpressionsValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidTransformsExpressions, + Message: strings.Join(messages, "\n\n"), + }) + } else { + conditions = append(conditions, &metav1.Condition{ + Type: typeTransformsExpressionsValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the expressions specified by .spec.identityProviders[].transforms.expressions[] are valid", + }) + } + return conditions +} + +func appendTransformsExamplesPassedCondition(messages []string, conditions []*metav1.Condition) []*metav1.Condition { + if len(messages) > 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeTransformsExamplesPassed, + Status: metav1.ConditionFalse, + Reason: reasonTransformsExamplesFailed, + Message: strings.Join(messages, "\n\n"), + }) + } else { + conditions = append(conditions, &metav1.Condition{ + Type: typeTransformsExamplesPassed, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the examples specified by .spec.identityProviders[].transforms.examples[] had no errors", + }) + } + return conditions +} + +func appendIdentityProviderDuplicateDisplayNamesCondition(duplicateDisplayNames sets.Set[string], conditions []*metav1.Condition) []*metav1.Condition { + if duplicateDisplayNames.Len() > 0 { + conditions = append(conditions, &metav1.Condition{ + Type: typeIdentityProvidersDisplayNamesUnique, + Status: metav1.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, &metav1.Condition{ + Type: typeIdentityProvidersDisplayNamesUnique, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the names specified by .spec.identityProviders[].displayName are unique", + }) + } + return conditions +} + +func appendIssuerURLValidCondition(err error, conditions []*metav1.Condition) []*metav1.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, &metav1.Condition{ + Type: typeIssuerURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidIssuerURL, + Message: err.Error(), + }) + } else { + conditions = append(conditions, &metav1.Condition{ + Type: typeIssuerURLValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "spec.issuer is a valid URL", + }) + } + return conditions +} + +func (c *federationDomainWatcherController) updateStatus( + ctx context.Context, + federationDomain *configv1alpha1.FederationDomain, + conditions []*metav1.Condition, +) error { + updated := federationDomain.DeepCopy() + + if hadErrorCondition(conditions) { + updated.Status.Phase = configv1alpha1.FederationDomainPhaseError + conditions = append(conditions, &metav1.Condition{ + Type: typeReady, + Status: metav1.ConditionFalse, + Reason: reasonNotReady, + Message: "the FederationDomain is not ready: see other conditions for details", + }) + } else { + updated.Status.Phase = configv1alpha1.FederationDomainPhaseReady + conditions = append(conditions, &metav1.Condition{ + Type: typeReady, + Status: metav1.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().WithName(controllerName), 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 +} + +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) sortedAllowedKinds() []string { + return sortAndQuote(c.allowedKinds.UnsortedList()) +} + +type transformsValidationErrorMessages struct { + errorsForExpressions []string + errorsForExamples []string +} + +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 []*metav1.Condition) []*metav1.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, &metav1.Condition{ + Type: typeIssuerIsUnique, + Status: metav1.ConditionUnknown, + Reason: reasonUnableToValidate, + Message: "unable to check if spec.issuer is unique among all FederationDomains because URL cannot be parsed", + }) + conditions = append(conditions, &metav1.Condition{ + Type: typeOneTLSSecretPerIssuerHostname, + Status: metav1.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, &metav1.Condition{ + Type: typeIssuerIsUnique, + Status: metav1.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, &metav1.Condition{ + Type: typeIssuerIsUnique, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "spec.issuer is unique among all FederationDomains", + }) + } + + if len(v.uniqueSecretNamesPerIssuerAddress[issuerURLToHostnameKey(issuerURL)]) > 1 { + conditions = append(conditions, &metav1.Condition{ + Type: typeOneTLSSecretPerIssuerHostname, + Status: metav1.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, &metav1.Condition{ + Type: typeOneTLSSecretPerIssuerHostname, + Status: metav1.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) - issuerURLToIssuerKey := func(issuerURL *url.URL) string { - return fmt.Sprintf("%s://%s%s", issuerURL.Scheme, strings.ToLower(issuerURL.Host), issuerURL.Path) - } // 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 @@ -87,12 +925,11 @@ func (c *federationDomainWatcherController) Sync(ctx controllerlib.Context) erro // 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) - issuerURLToHostnameKey := lowercaseHostWithoutPort for _, federationDomain := range federationDomains { issuerURL, err := url.Parse(federationDomain.Spec.Issuer) if err != nil { - continue // Skip url parse errors because they will be validated again below. + continue // Skip url parse errors because they will be handled in the Validate function. } issuerCounts[issuerURLToIssuerKey(issuerURL)]++ @@ -107,106 +944,23 @@ func (c *federationDomainWatcherController) Sync(ctx controllerlib.Context) erro } } - var errs []error - - federationDomainIssuers := make([]*provider.FederationDomainIssuer, 0) - for _, federationDomain := range federationDomains { - issuerURL, urlParseErr := url.Parse(federationDomain.Spec.Issuer) - - // Skip url parse errors because they will be validated below. - if urlParseErr == nil { - if issuerCount := issuerCounts[issuerURLToIssuerKey(issuerURL)]; issuerCount > 1 { - if err := c.updateStatus( - ctx.Context, - federationDomain.Namespace, - federationDomain.Name, - configv1alpha1.DuplicateFederationDomainStatusCondition, - "Duplicate issuer: "+federationDomain.Spec.Issuer, - ); err != nil { - errs = append(errs, fmt.Errorf("could not update status: %w", err)) - } - continue - } - } - - // Skip url parse errors because they will be validated below. - if urlParseErr == nil && len(uniqueSecretNamesPerIssuerAddress[issuerURLToHostnameKey(issuerURL)]) > 1 { - if err := c.updateStatus( - ctx.Context, - federationDomain.Namespace, - federationDomain.Name, - configv1alpha1.SameIssuerHostMustUseSameSecretFederationDomainStatusCondition, - "Issuers with the same DNS hostname (address not including port) must use the same secretName: "+issuerURLToHostnameKey(issuerURL), - ); err != nil { - errs = append(errs, fmt.Errorf("could not update status: %w", err)) - } - continue - } - - federationDomainIssuer, err := provider.NewFederationDomainIssuer(federationDomain.Spec.Issuer) // This validates the Issuer URL. - if err != nil { - if err := c.updateStatus( - ctx.Context, - federationDomain.Namespace, - federationDomain.Name, - configv1alpha1.InvalidFederationDomainStatusCondition, - "Invalid: "+err.Error(), - ); err != nil { - errs = append(errs, fmt.Errorf("could not update status: %w", err)) - } - continue - } - - if err := c.updateStatus( - ctx.Context, - federationDomain.Namespace, - federationDomain.Name, - configv1alpha1.SuccessFederationDomainStatusCondition, - "Provider successfully created", - ); err != nil { - errs = append(errs, fmt.Errorf("could not update status: %w", err)) - continue - } - - federationDomainIssuers = append(federationDomainIssuers, federationDomainIssuer) + return &crossFederationDomainConfigValidator{ + issuerCounts: issuerCounts, + uniqueSecretNamesPerIssuerAddress: uniqueSecretNamesPerIssuerAddress, } - - c.providerSetter.SetProviders(federationDomainIssuers...) - - return errors.NewAggregate(errs) } -func (c *federationDomainWatcherController) updateStatus( - ctx context.Context, - namespace, name string, - status configv1alpha1.FederationDomainStatusCondition, - message string, -) error { - return retry.RetryOnConflict(retry.DefaultRetry, func() error { - federationDomain, err := c.client.ConfigV1alpha1().FederationDomains(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("get failed: %w", err) +func hadErrorCondition(conditions []*metav1.Condition) bool { + for _, c := range conditions { + if c.Status != metav1.ConditionTrue { + return true } - - if federationDomain.Status.Status == status && federationDomain.Status.Message == message { - return nil - } - - plog.Debug( - "attempting status update", - "federationdomain", - klog.KRef(namespace, name), - "status", - status, - "message", - message, - ) - federationDomain.Status.Status = status - federationDomain.Status.Message = message - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(c.clock.Now())) - _, err = c.client.ConfigV1alpha1().FederationDomains(namespace).UpdateStatus(ctx, federationDomain, metav1.UpdateOptions{}) - return err - }) + } + return false } -func timePtr(t metav1.Time) *metav1.Time { return &t } +func stringSetsEqual(a []string, b []string) bool { + aSet := sets.New(a...) + bSet := sets.New(b...) + return aSet.Equal(bSet) +} diff --git a/internal/controller/supervisorconfig/federation_domain_watcher_test.go b/internal/controller/supervisorconfig/federation_domain_watcher_test.go index bf19af46..ba6055b5 100644 --- a/internal/controller/supervisorconfig/federation_domain_watcher_test.go +++ b/internal/controller/supervisorconfig/federation_domain_watcher_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorconfig @@ -8,1021 +8,2286 @@ import ( "errors" "fmt" "net/url" - "reflect" - "sync" + "sort" "testing" "time" - "github.com/sclevine/spec" - "github.com/sclevine/spec/report" "github.com/stretchr/testify/require" - k8serrors "k8s.io/apimachinery/pkg/api/errors" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" coretesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" + "k8s.io/utils/ptr" - "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" pinnipedfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" pinnipedinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions" + "go.pinniped.dev/internal/celtransformer" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/federationdomain/federationdomainproviders" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/testutil" ) -func TestInformerFilters(t *testing.T) { - spec.Run(t, "informer filters", func(t *testing.T, when spec.G, it spec.S) { - var r *require.Assertions - var observableWithInformerOption *testutil.ObservableWithInformerOption - var configMapInformerFilter controllerlib.Filter +func TestFederationDomainWatcherControllerInformerFilters(t *testing.T) { + t.Parallel() - it.Before(func() { - r = require.New(t) - observableWithInformerOption = testutil.NewObservableWithInformerOption() - federationDomainInformer := pinnipedinformers.NewSharedInformerFactoryWithOptions(nil, 0).Config().V1alpha1().FederationDomains() - _ = NewFederationDomainWatcherController( + federationDomainInformer := pinnipedinformers.NewSharedInformerFactoryWithOptions(nil, 0).Config().V1alpha1().FederationDomains() + oidcIdentityProviderInformer := pinnipedinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().OIDCIdentityProviders() + ldapIdentityProviderInformer := pinnipedinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().LDAPIdentityProviders() + adIdentityProviderInformer := pinnipedinformers.NewSharedInformerFactoryWithOptions(nil, 0).IDP().V1alpha1().ActiveDirectoryIdentityProviders() + + tests := []struct { + name string + obj metav1.Object + informer controllerlib.InformerGetter + wantAdd bool + wantUpdate bool + wantDelete bool + }{ + { + name: "any FederationDomain changes", + obj: &configv1alpha1.FederationDomain{}, + informer: federationDomainInformer, + wantAdd: true, + wantUpdate: true, + wantDelete: true, + }, + { + name: "any OIDCIdentityProvider adds or deletes, but updates are ignored", + obj: &idpv1alpha1.OIDCIdentityProvider{}, + informer: oidcIdentityProviderInformer, + wantAdd: true, + wantUpdate: false, + wantDelete: true, + }, + { + name: "any LDAPIdentityProvider adds or deletes, but updates are ignored", + obj: &idpv1alpha1.LDAPIdentityProvider{}, + informer: ldapIdentityProviderInformer, + wantAdd: true, + wantUpdate: false, + wantDelete: true, + }, + { + name: "any ActiveDirectoryIdentityProvider adds or deletes, but updates are ignored", + obj: &idpv1alpha1.ActiveDirectoryIdentityProvider{}, + informer: adIdentityProviderInformer, + wantAdd: true, + wantUpdate: false, + wantDelete: true, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + withInformer := testutil.NewObservableWithInformerOption() + + NewFederationDomainWatcherController( nil, + "", nil, nil, federationDomainInformer, - observableWithInformerOption.WithInformer, // make it possible to observe the behavior of the Filters + oidcIdentityProviderInformer, + ldapIdentityProviderInformer, + adIdentityProviderInformer, + withInformer.WithInformer, // make it possible to observe the behavior of the Filters ) - configMapInformerFilter = observableWithInformerOption.GetFilterForInformer(federationDomainInformer) + + unrelatedObj := corev1.Secret{} + filter := withInformer.GetFilterForInformer(test.informer) + require.Equal(t, test.wantAdd, filter.Add(test.obj)) + require.Equal(t, test.wantUpdate, filter.Update(&unrelatedObj, test.obj)) + require.Equal(t, test.wantUpdate, filter.Update(test.obj, &unrelatedObj)) + require.Equal(t, test.wantDelete, filter.Delete(test.obj)) }) - - when("watching FederationDomain objects", func() { - var subject controllerlib.Filter - var target, otherNamespace, otherName *v1alpha1.FederationDomain - - it.Before(func() { - subject = configMapInformerFilter - target = &v1alpha1.FederationDomain{ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "some-namespace"}} - otherNamespace = &v1alpha1.FederationDomain{ObjectMeta: metav1.ObjectMeta{Name: "some-name", Namespace: "other-namespace"}} - otherName = &v1alpha1.FederationDomain{ObjectMeta: metav1.ObjectMeta{Name: "other-name", Namespace: "some-namespace"}} - }) - - when("any FederationDomain changes", func() { - it("returns true to trigger the sync method", func() { - r.True(subject.Add(target)) - r.True(subject.Add(otherName)) - r.True(subject.Add(otherNamespace)) - r.True(subject.Update(target, otherName)) - r.True(subject.Update(otherName, otherName)) - r.True(subject.Update(otherNamespace, otherName)) - r.True(subject.Update(otherName, target)) - r.True(subject.Update(otherName, otherName)) - r.True(subject.Update(otherName, otherNamespace)) - r.True(subject.Delete(target)) - r.True(subject.Delete(otherName)) - r.True(subject.Delete(otherNamespace)) - }) - }) - }) - }, spec.Parallel(), spec.Report(report.Terminal{})) + } } -type fakeProvidersSetter struct { - SetProvidersWasCalled bool - FederationDomainsReceived []*provider.FederationDomainIssuer +type fakeFederationDomainsSetter struct { + SetFederationDomainsWasCalled bool + FederationDomainsReceived []*federationdomainproviders.FederationDomainIssuer } -func (f *fakeProvidersSetter) SetProviders(federationDomains ...*provider.FederationDomainIssuer) { - f.SetProvidersWasCalled = true +func (f *fakeFederationDomainsSetter) SetFederationDomains(federationDomains ...*federationdomainproviders.FederationDomainIssuer) { + f.SetFederationDomainsWasCalled = true f.FederationDomainsReceived = federationDomains } -func TestSync(t *testing.T) { - spec.Run(t, "Sync", func(t *testing.T, when spec.G, it spec.S) { - const namespace = "some-namespace" +var federationDomainGVR = schema.GroupVersionResource{ + Group: configv1alpha1.SchemeGroupVersion.Group, + Version: configv1alpha1.SchemeGroupVersion.Version, + Resource: "federationdomains", +} - var r *require.Assertions +func TestTestFederationDomainWatcherControllerSync(t *testing.T) { + t.Parallel() - var subject controllerlib.Controller - var federationDomainInformerClient *pinnipedfake.Clientset - var federationDomainInformers pinnipedinformers.SharedInformerFactory - var pinnipedAPIClient *pinnipedfake.Clientset - var cancelContext context.Context - var cancelContextCancelFunc context.CancelFunc - var syncContext *controllerlib.Context - var frozenNow time.Time - var providersSetter *fakeProvidersSetter - var federationDomainGVR schema.GroupVersionResource + const namespace = "some-namespace" + const apiGroupSuffix = "custom.suffix.pinniped.dev" + const apiGroupSupervisor = "idp.supervisor." + apiGroupSuffix - // Defer starting the informers until the last possible moment so that the - // nested Before's can keep adding things to the informer caches. - var startInformersAndController = func() { - // Set this at the last second to allow for injection of server override. - subject = NewFederationDomainWatcherController( - providersSetter, - clocktesting.NewFakeClock(frozenNow), - pinnipedAPIClient, - federationDomainInformers.Config().V1alpha1().FederationDomains(), - controllerlib.WithInformer, - ) + frozenNow := time.Date(2020, time.September, 23, 7, 42, 0, 0, time.Local) + frozenMetav1Now := metav1.NewTime(frozenNow) - // Set this at the last second to support calling subject.Name(). - syncContext = &controllerlib.Context{ - Context: cancelContext, - Name: subject.Name(), - Key: controllerlib.Key{ - Namespace: namespace, - Name: "config-name", - }, - } + oidcIdentityProvider := &idpv1alpha1.OIDCIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-oidc-idp", + Namespace: namespace, + UID: "some-oidc-uid", + }, + } - // Must start informers before calling TestRunSynchronously() - federationDomainInformers.Start(cancelContext.Done()) - controllerlib.TestRunSynchronously(t, subject) + ldapIdentityProvider := &idpv1alpha1.LDAPIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-ldap-idp", + Namespace: namespace, + UID: "some-ldap-uid", + }, + } + + adIdentityProvider := &idpv1alpha1.ActiveDirectoryIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{ + Name: "some-ad-idp", + Namespace: namespace, + UID: "some-ad-uid", + }, + } + + federationDomain1 := &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://issuer1.com"}, + } + + federationDomain2 := &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config2", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://issuer2.com"}, + } + + invalidIssuerURLFederationDomain := &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-config", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://invalid-issuer.com?some=query"}, + } + + federationDomainIssuerWithIDPs := func(t *testing.T, fedDomainIssuer string, fdIDPs []*federationdomainproviders.FederationDomainIdentityProvider) *federationdomainproviders.FederationDomainIssuer { + fdIssuer, err := federationdomainproviders.NewFederationDomainIssuer(fedDomainIssuer, fdIDPs) + require.NoError(t, err) + return fdIssuer + } + + federationDomainIssuerWithDefaultIDP := func(t *testing.T, fedDomainIssuer string, idpObjectMeta metav1.ObjectMeta) *federationdomainproviders.FederationDomainIssuer { + fdIDP := &federationdomainproviders.FederationDomainIdentityProvider{ + DisplayName: idpObjectMeta.Name, + UID: idpObjectMeta.UID, + Transforms: idtransform.NewTransformationPipeline(), } + fdIssuer, err := federationdomainproviders.NewFederationDomainIssuerWithDefaultIDP(fedDomainIssuer, fdIDP) + require.NoError(t, err) + return fdIssuer + } - it.Before(func() { - r = require.New(t) + happyReadyCondition := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "Ready", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: fmt.Sprintf("the FederationDomain is ready and its endpoints are available: "+ + "the discovery endpoint is %s/.well-known/openid-configuration", issuer), + } + } - providersSetter = &fakeProvidersSetter{} - frozenNow = time.Date(2020, time.September, 23, 7, 42, 0, 0, time.Local) + sadReadyCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "Ready", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + } + } - cancelContext, cancelContextCancelFunc = context.WithCancel(context.Background()) + happyIssuerIsUniqueCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerIsUnique", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "spec.issuer is unique among all FederationDomains", + } + } - federationDomainInformerClient = pinnipedfake.NewSimpleClientset() - federationDomainInformers = pinnipedinformers.NewSharedInformerFactory(federationDomainInformerClient, 0) - pinnipedAPIClient = pinnipedfake.NewSimpleClientset() + unknownIssuerIsUniqueCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerIsUnique", + Status: "Unknown", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToValidate", + Message: "unable to check if spec.issuer is unique among all FederationDomains because URL cannot be parsed", + } + } - federationDomainGVR = schema.GroupVersionResource{ - Group: v1alpha1.SchemeGroupVersion.Group, - Version: v1alpha1.SchemeGroupVersion.Version, - Resource: "federationdomains", + sadIssuerIsUniqueCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerIsUnique", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "DuplicateIssuer", + Message: "multiple FederationDomains have the same spec.issuer URL: these URLs must be unique (can use different hosts or paths)", + } + } + + happyOneTLSSecretPerIssuerHostnameCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "OneTLSSecretPerIssuerHostname", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "all FederationDomains are using the same TLS secret when using the same hostname in the spec.issuer URL", + } + } + + unknownOneTLSSecretPerIssuerHostnameCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "OneTLSSecretPerIssuerHostname", + Status: "Unknown", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToValidate", + 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", + } + } + + sadOneTLSSecretPerIssuerHostnameCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "OneTLSSecretPerIssuerHostname", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "DifferentSecretRefsFound", + 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", + } + } + + happyIssuerURLValidCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "spec.issuer is a valid URL", + } + } + + sadIssuerURLValidConditionCannotHaveQuery := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidIssuerURL", + Message: "issuer must not have query", + } + } + + sadIssuerURLValidConditionCannotParse := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidIssuerURL", + Message: `could not parse issuer as URL: parse ":/host//path": missing protocol scheme`, + } + } + + happyIdentityProvidersFoundConditionLegacyConfigurationSuccess := func(idpName string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersFound", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "LegacyConfigurationSuccess", + 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)", idpName), + } + } + + happyIdentityProvidersFoundConditionSuccess := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersFound", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the resources specified by .spec.identityProviders[].objectRef were found", + } + } + + sadIdentityProvidersFoundConditionLegacyConfigurationIdentityProviderNotFound := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersFound", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "LegacyConfigurationIdentityProviderNotFound", + Message: "no resources were specified by .spec.identityProviders[].objectRef and no identity provider " + + "resources have been found: please create an identity provider resource", + } + } + + sadIdentityProvidersFoundConditionIdentityProviderNotSpecified := func(idpCRsCount int, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersFound", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "IdentityProviderNotSpecified", + Message: fmt.Sprintf("no resources were specified by .spec.identityProviders[].objectRef "+ + "and %d identity provider resources have been found: "+ + "please update .spec.identityProviders to specify which identity providers "+ + "this federation domain should use", idpCRsCount), + } + } + + sadIdentityProvidersFoundConditionIdentityProvidersObjectRefsNotFound := func(errorMessages string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersFound", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "IdentityProvidersObjectRefsNotFound", + Message: errorMessages, + } + } + + happyDisplayNamesUniqueCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersDisplayNamesUnique", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the names specified by .spec.identityProviders[].displayName are unique", + } + } + + sadDisplayNamesUniqueCondition := func(duplicateNames string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersDisplayNamesUnique", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "DuplicateDisplayNames", + Message: fmt.Sprintf("the names specified by .spec.identityProviders[].displayName contain duplicates: %s", duplicateNames), + } + } + + happyTransformationExpressionsCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TransformsExpressionsValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the expressions specified by .spec.identityProviders[].transforms.expressions[] are valid", + } + } + + sadTransformationExpressionsCondition := func(errorMessages string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TransformsExpressionsValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidTransformsExpressions", + Message: errorMessages, + } + } + + happyTransformationExamplesCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TransformsExamplesPassed", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the examples specified by .spec.identityProviders[].transforms.examples[] had no errors", + } + } + + sadTransformationExamplesCondition := func(errorMessages string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TransformsExamplesPassed", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "TransformsExamplesFailed", + Message: errorMessages, + } + } + + happyAPIGroupSuffixCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersObjectRefAPIGroupSuffixValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the API groups specified by .spec.identityProviders[].objectRef.apiGroup are recognized", + } + } + + sadAPIGroupSuffixCondition := func(badApiGroups string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersObjectRefAPIGroupSuffixValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "APIGroupUnrecognized", + Message: fmt.Sprintf("some API groups specified by .spec.identityProviders[].objectRef.apiGroup "+ + "are not recognized (should be \"idp.supervisor.%s\"): %s", apiGroupSuffix, badApiGroups), + } + } + + happyKindCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersObjectRefKindValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the kinds specified by .spec.identityProviders[].objectRef.kind are recognized", + } + } + + sadKindCondition := func(badKinds string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IdentityProvidersObjectRefKindValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "KindUnrecognized", + Message: fmt.Sprintf(`some kinds specified by .spec.identityProviders[].objectRef.kind are `+ + `not recognized (should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): %s`, badKinds), + } + } + + sortConditionsByType := func(c []metav1.Condition) []metav1.Condition { + cp := make([]metav1.Condition, len(c)) + copy(cp, c) + sort.SliceStable(cp, func(i, j int) bool { + return cp[i].Type < cp[j].Type + }) + return cp + } + + replaceConditions := func(conditions []metav1.Condition, sadConditions []metav1.Condition) []metav1.Condition { + for _, sadReplaceCondition := range sadConditions { + for origIndex, origCondition := range conditions { + if origCondition.Type == sadReplaceCondition.Type { + conditions[origIndex] = sadReplaceCondition + break + } } + } + return conditions + } + + allHappyConditionsSuccess := func(issuer string, time metav1.Time, observedGeneration int64) []metav1.Condition { + return sortConditionsByType([]metav1.Condition{ + happyTransformationExamplesCondition(frozenMetav1Now, 123), + happyTransformationExpressionsCondition(frozenMetav1Now, 123), + happyKindCondition(frozenMetav1Now, 123), + happyAPIGroupSuffixCondition(frozenMetav1Now, 123), + happyDisplayNamesUniqueCondition(frozenMetav1Now, 123), + happyIdentityProvidersFoundConditionSuccess(frozenMetav1Now, 123), + happyIssuerIsUniqueCondition(frozenMetav1Now, 123), + happyIssuerURLValidCondition(frozenMetav1Now, 123), + happyOneTLSSecretPerIssuerHostnameCondition(frozenMetav1Now, 123), + happyReadyCondition(issuer, frozenMetav1Now, 123), }) + } - it.After(func() { - cancelContextCancelFunc() - }) + allHappyConditionsLegacyConfigurationSuccess := func(issuer string, idpName string, time metav1.Time, observedGeneration int64) []metav1.Condition { + return replaceConditions( + allHappyConditionsSuccess(issuer, time, observedGeneration), + []metav1.Condition{ + happyIdentityProvidersFoundConditionLegacyConfigurationSuccess(idpName, time, observedGeneration), + }, + ) + } - when("there are some valid FederationDomains in the informer", func() { - var ( - federationDomain1 *v1alpha1.FederationDomain - federationDomain2 *v1alpha1.FederationDomain - ) + invalidIssuerURL := ":/host//path" + _, err := url.Parse(invalidIssuerURL) //nolint:staticcheck // Yes, this URL is intentionally invalid. + require.Error(t, err) - it.Before(func() { - federationDomain1 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://issuer1.com"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomain1)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomain1)) - - federationDomain2 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "config2", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://issuer2.com"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomain2)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomain2)) - }) - - it("calls the ProvidersSetter", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - provider1, err := provider.NewFederationDomainIssuer(federationDomain1.Spec.Issuer) - r.NoError(err) - - provider2, err := provider.NewFederationDomainIssuer(federationDomain2.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.ElementsMatch( - []*provider.FederationDomainIssuer{ - provider1, - provider2, + tests := []struct { + name string + inputObjects []runtime.Object + configClient func(*pinnipedfake.Clientset) + wantErr string + wantStatusUpdates []*configv1alpha1.FederationDomain + wantFDIssuers []*federationdomainproviders.FederationDomainIssuer + }{ + { + name: "when there are no FederationDomains, no update actions happen and the list of FederationDomainIssuers is set to the empty list", + inputObjects: []runtime.Object{}, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + }, + { + name: "legacy config: when no identity provider is specified on federation domains, but exactly one OIDC identity " + + "provider resource exists on cluster, the controller will set a default IDP on each federation domain " + + "matching the only identity provider found", + inputObjects: []runtime.Object{ + federationDomain1, + federationDomain2, + oidcIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "legacy config: when no identity provider is specified on federation domains, but exactly one LDAP identity " + + "provider resource exists on cluster, the controller will set a default IDP on each federation domain " + + "matching the only identity provider found", + inputObjects: []runtime.Object{ + federationDomain1, + federationDomain2, + ldapIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, ldapIdentityProvider.ObjectMeta), + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, ldapIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, ldapIdentityProvider.Name, frozenMetav1Now, 123), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, ldapIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "legacy config: when no identity provider is specified on federation domains, but exactly one AD identity " + + "provider resource exists on cluster, the controller will set a default IDP on each federation domain " + + "matching the only identity provider found", + inputObjects: []runtime.Object{ + federationDomain1, + federationDomain2, + adIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, adIdentityProvider.ObjectMeta), + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, adIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, adIdentityProvider.Name, frozenMetav1Now, 123), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, adIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when there are two valid FederationDomains, but one is already up to date, the sync loop only updates " + + "the out-of-date FederationDomain", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: federationDomain1.Name, Namespace: federationDomain1.Namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: federationDomain1.Spec.Issuer}, + Status: configv1alpha1.FederationDomainStatus{ + Phase: configv1alpha1.FederationDomainPhaseReady, + Conditions: allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), }, - providersSetter.FederationDomainsReceived, - ) - }) - - it("updates the status to success in the FederationDomains", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - federationDomain1.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain1.Status.Message = "Provider successfully created" - federationDomain1.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomain2.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain2.Status.Message = "Provider successfully created" - federationDomain2.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain1.Namespace, - federationDomain1.Name, + }, + federationDomain2, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + // only one update, because the other FederationDomain already had the right status + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when the status of the FederationDomains is based on an old generation, it is updated", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: federationDomain1.Name, Namespace: federationDomain1.Namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: federationDomain1.Spec.Issuer}, + Status: configv1alpha1.FederationDomainStatus{ + Phase: configv1alpha1.FederationDomainPhaseReady, + Conditions: allHappyConditionsLegacyConfigurationSuccess( + federationDomain1.Spec.Issuer, + oidcIdentityProvider.Name, + frozenMetav1Now, + 2, // this is an older generation + ), + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + // only one update, because the other FederationDomain already had the right status + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess( + federationDomain1.Spec.Issuer, + oidcIdentityProvider.Name, + frozenMetav1Now, + 123, // all conditions are updated to the new observed generation ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain1.Namespace, - federationDomain1, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain2.Namespace, - federationDomain2.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain2.Namespace, - federationDomain2, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - - when("one FederationDomain is already up to date", func() { - it.Before(func() { - federationDomain1.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain1.Status.Message = "Provider successfully created" - federationDomain1.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - r.NoError(pinnipedAPIClient.Tracker().Update(federationDomainGVR, federationDomain1, federationDomain1.Namespace)) - r.NoError(federationDomainInformerClient.Tracker().Update(federationDomainGVR, federationDomain1, federationDomain1.Namespace)) - }) - - it("only updates the out-of-date FederationDomain", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - federationDomain2.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain2.Status.Message = "Provider successfully created" - federationDomain2.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain1.Namespace, - federationDomain1.Name, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain2.Namespace, - federationDomain2.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain2.Namespace, - federationDomain2, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - - it("calls the ProvidersSetter with both FederationDomain's", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - provider1, err := provider.NewFederationDomainIssuer(federationDomain1.Spec.Issuer) - r.NoError(err) - - provider2, err := provider.NewFederationDomainIssuer(federationDomain2.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.ElementsMatch( - []*provider.FederationDomainIssuer{ - provider1, - provider2, - }, - providersSetter.FederationDomainsReceived, - ) - }) - }) - - when("updating only one FederationDomain fails for a reason other than conflict", func() { - it.Before(func() { - once := sync.Once{} - pinnipedAPIClient.PrependReactor( - "update", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { - var err error - once.Do(func() { - err = errors.New("some update error") - }) - return true, nil, err - }, - ) - }) - - it("sets the provider that it could actually update in the API", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: some update error") - - provider1, err := provider.NewFederationDomainIssuer(federationDomain1.Spec.Issuer) - r.NoError(err) - - provider2, err := provider.NewFederationDomainIssuer(federationDomain2.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.Len(providersSetter.FederationDomainsReceived, 1) - r.True( - reflect.DeepEqual(providersSetter.FederationDomainsReceived[0], provider1) || - reflect.DeepEqual(providersSetter.FederationDomainsReceived[0], provider2), - ) - }) - - it("returns an error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: some update error") - - federationDomain1.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain1.Status.Message = "Provider successfully created" - federationDomain1.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomain2.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain2.Status.Message = "Provider successfully created" - federationDomain2.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain1.Namespace, - federationDomain1.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain1.Namespace, - federationDomain1, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain2.Namespace, - federationDomain2.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain2.Namespace, - federationDomain2, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - }) - - when("there are errors updating the FederationDomains", func() { - var ( - federationDomain *v1alpha1.FederationDomain - ) - - it.Before(func() { - federationDomain = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "config", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://issuer.com"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomain)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomain)) - }) - - when("there is a conflict while updating an FederationDomain", func() { - it.Before(func() { - once := sync.Once{} - pinnipedAPIClient.PrependReactor( - "update", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { - var err error - once.Do(func() { - err = k8serrors.NewConflict(schema.GroupResource{}, "", nil) - }) - return true, nil, err - }, - ) - }) - - it("retries updating the FederationDomain", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - federationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain.Status.Message = "Provider successfully created" - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain.Namespace, - federationDomain, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain.Namespace, - federationDomain, - ), - } - r.Equal(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - - when("updating the FederationDomain fails for a reason other than conflict", func() { - it.Before(func() { - pinnipedAPIClient.PrependReactor( - "update", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { + ), + }, + }, + { + name: "when there are two valid FederationDomains, but updating one fails, the status on the FederationDomain will not change", + inputObjects: []runtime.Object{ + federationDomain1, + federationDomain2, + oidcIdentityProvider, + }, + configClient: func(client *pinnipedfake.Clientset) { + client.PrependReactor( + "update", + "federationdomains", + func(action coretesting.Action) (bool, runtime.Object, error) { + fd := action.(coretesting.UpdateAction).GetObject().(*configv1alpha1.FederationDomain) + if fd.Name == federationDomain1.Name { return true, nil, errors.New("some update error") - }, - ) - }) - - it("returns an error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: some update error") - - federationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain.Status.Message = "Provider successfully created" - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain.Namespace, - federationDomain, - ), - } - r.Equal(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - - when("there is an error when getting the FederationDomain", func() { - it.Before(func() { - pinnipedAPIClient.PrependReactor( - "get", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { - return true, nil, errors.New("some get error") - }, - ) - }) - - it("returns the get error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: get failed: some get error") - - federationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain.Status.Message = "Provider successfully created" - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - } - r.Equal(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - }) - - when("there are both valid and invalid FederationDomains in the informer", func() { - var ( - validFederationDomain *v1alpha1.FederationDomain - invalidFederationDomain *v1alpha1.FederationDomain - ) - - it.Before(func() { - validFederationDomain = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "valid-config", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://valid-issuer.com"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(validFederationDomain)) - r.NoError(federationDomainInformerClient.Tracker().Add(validFederationDomain)) - - invalidFederationDomain = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "invalid-config", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://invalid-issuer.com?some=query"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(invalidFederationDomain)) - r.NoError(federationDomainInformerClient.Tracker().Add(invalidFederationDomain)) - }) - - it("calls the ProvidersSetter with the valid provider", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - validProvider, err := provider.NewFederationDomainIssuer(validFederationDomain.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.Equal( - []*provider.FederationDomainIssuer{ - validProvider, + } + return false, nil, nil }, - providersSetter.FederationDomainsReceived, ) - }) - - it("updates the status to success/invalid in the FederationDomains", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - validFederationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - validFederationDomain.Status.Message = "Provider successfully created" - validFederationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - invalidFederationDomain.Status.Status = v1alpha1.InvalidFederationDomainStatusCondition - invalidFederationDomain.Status.Message = "Invalid: issuer must not have query" - invalidFederationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - invalidFederationDomain.Namespace, - invalidFederationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - invalidFederationDomain.Namespace, - invalidFederationDomain, - ), - coretesting.NewGetAction( - federationDomainGVR, - validFederationDomain.Namespace, - validFederationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - validFederationDomain.Namespace, - validFederationDomain, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - - when("updating only the invalid FederationDomain fails for a reason other than conflict", func() { - it.Before(func() { - pinnipedAPIClient.PrependReactor( - "update", - "federationdomains", - func(action coretesting.Action) (bool, runtime.Object, error) { - updateAction := action.(coretesting.UpdateActionImpl) - federationDomain := updateAction.Object.(*v1alpha1.FederationDomain) - if federationDomain.Name == validFederationDomain.Name { - return true, nil, nil - } - + }, + wantErr: "could not update status: some update error", + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, federationDomain1.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when there are both valid and invalid FederationDomains, the status will be correctly set on each " + + "FederationDomain individually", + inputObjects: []runtime.Object{ + invalidIssuerURLFederationDomain, + federationDomain2, + oidcIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + // only the valid FederationDomain + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(invalidIssuerURLFederationDomain, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadIssuerURLValidConditionCannotHaveQuery(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when there are both valid and invalid FederationDomains, but updating the invalid one fails, the " + + "existing status will be unchanged", + inputObjects: []runtime.Object{ + invalidIssuerURLFederationDomain, + federationDomain2, + oidcIdentityProvider, + }, + configClient: func(client *pinnipedfake.Clientset) { + client.PrependReactor( + "update", + "federationdomains", + func(action coretesting.Action) (bool, runtime.Object, error) { + fd := action.(coretesting.UpdateAction).GetObject().(*configv1alpha1.FederationDomain) + if fd.Name == invalidIssuerURLFederationDomain.Name { return true, nil, errors.New("some update error") - }, - ) - }) - - it("sets the provider that it could actually update in the API", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: some update error") - - validProvider, err := provider.NewFederationDomainIssuer(validFederationDomain.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.Equal( - []*provider.FederationDomainIssuer{ - validProvider, - }, - providersSetter.FederationDomainsReceived, - ) - }) - - it("returns an error", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, "could not update status: some update error") - - validFederationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - validFederationDomain.Status.Message = "Provider successfully created" - validFederationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - invalidFederationDomain.Status.Status = v1alpha1.InvalidFederationDomainStatusCondition - invalidFederationDomain.Status.Message = "Invalid: issuer must not have query" - invalidFederationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - invalidFederationDomain.Namespace, - invalidFederationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - invalidFederationDomain.Namespace, - invalidFederationDomain, - ), - coretesting.NewGetAction( - federationDomainGVR, - validFederationDomain.Namespace, - validFederationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - validFederationDomain.Namespace, - validFederationDomain, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - }) - - when("there are FederationDomains with duplicate issuer names in the informer", func() { - var ( - federationDomainDuplicate1 *v1alpha1.FederationDomain - federationDomainDuplicate2 *v1alpha1.FederationDomain - federationDomain *v1alpha1.FederationDomain - ) - - it.Before(func() { - // Hostnames are case-insensitive, so consider them to be duplicates if they only differ by case. - // Paths are case-sensitive, so having a path that differs only by case makes a new issuer. - federationDomainDuplicate1 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "duplicate1", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://iSSueR-duPlicAte.cOm/a"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainDuplicate1)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainDuplicate1)) - federationDomainDuplicate2 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "duplicate2", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://issuer-duplicate.com/a"}, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainDuplicate2)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainDuplicate2)) - - federationDomain = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "not-duplicate", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{Issuer: "https://issuer-duplicate.com/A"}, // different path - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomain)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomain)) - }) - - it("calls the ProvidersSetter with the non-duplicate", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - nonDuplicateProvider, err := provider.NewFederationDomainIssuer(federationDomain.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.Equal( - []*provider.FederationDomainIssuer{ - nonDuplicateProvider, + } + return false, nil, nil }, - providersSetter.FederationDomainsReceived, ) - }) - - it("updates the statuses", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - federationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain.Status.Message = "Provider successfully created" - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomainDuplicate1.Status.Status = v1alpha1.DuplicateFederationDomainStatusCondition - federationDomainDuplicate1.Status.Message = "Duplicate issuer: https://iSSueR-duPlicAte.cOm/a" - federationDomainDuplicate1.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomainDuplicate2.Status.Status = v1alpha1.DuplicateFederationDomainStatusCondition - federationDomainDuplicate2.Status.Message = "Duplicate issuer: https://issuer-duplicate.com/a" - federationDomainDuplicate2.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDuplicate1.Namespace, - federationDomainDuplicate1.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainDuplicate1.Namespace, - federationDomainDuplicate1, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDuplicate2.Namespace, - federationDomainDuplicate2.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainDuplicate2.Namespace, - federationDomainDuplicate2, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomain.Namespace, - federationDomain, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - - when("we cannot talk to the API", func() { - var count int - it.Before(func() { - pinnipedAPIClient.PrependReactor( - "get", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { - count++ - return true, nil, fmt.Errorf("some get error %d", count) - }, - ) - }) - - it("returns the get errors", func() { - expectedError := here.Doc(`[could not update status: get failed: some get error 1, could not update status: get failed: some get error 2, could not update status: get failed: some get error 3]`) - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, expectedError) - - federationDomain.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomain.Status.Message = "Provider successfully created" - federationDomain.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDuplicate1.Namespace, - federationDomainDuplicate1.Name, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDuplicate2.Namespace, - federationDomainDuplicate2.Name, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomain.Namespace, - federationDomain.Name, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - }) - }) - - when("there are FederationDomains with the same issuer DNS hostname using different secretNames", func() { - var ( - federationDomainSameIssuerAddress1 *v1alpha1.FederationDomain - federationDomainSameIssuerAddress2 *v1alpha1.FederationDomain - federationDomainDifferentIssuerAddress *v1alpha1.FederationDomain - federationDomainWithInvalidIssuerURL *v1alpha1.FederationDomain - ) - - it.Before(func() { - federationDomainSameIssuerAddress1 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "provider1", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{ + }, + wantErr: "could not update status: some update error", + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + // only the valid FederationDomain + federationDomainIssuerWithDefaultIDP(t, federationDomain2.Spec.Issuer, oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(invalidIssuerURLFederationDomain, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadIssuerURLValidConditionCannotHaveQuery(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when there are FederationDomains with duplicate issuer strings these particular FederationDomains " + + "will report error on IssuerUnique conditions", + inputObjects: []runtime.Object{ + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "duplicate1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://iSSueR-duPlicAte.cOm/a"}, + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "duplicate2", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://issuer-duplicate.com/a"}, + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "not-duplicate", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{Issuer: "https://issuer-duplicate.com/A"}, // different path (paths are case-sensitive) + }, + oidcIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, "https://issuer-duplicate.com/A", oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "duplicate1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess("https://iSSueR-duPlicAte.cOm/a", oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadIssuerIsUniqueCondition(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "duplicate2", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess("https://issuer-duplicate.com/a", oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadIssuerIsUniqueCondition(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "not-duplicate", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess("https://issuer-duplicate.com/A", oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "when there are FederationDomains with the same issuer DNS hostname using different secretNames these " + + "particular FederationDomains will report errors on OneTLSSecretPerIssuerHostname conditions", + inputObjects: []runtime.Object{ + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "fd1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ Issuer: "https://iSSueR-duPlicAte-adDress.cOm/path1", - TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, }, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainSameIssuerAddress1)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainSameIssuerAddress1)) - federationDomainSameIssuerAddress2 = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "provider2", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{ + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "fd2", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ // Validation treats these as the same DNS hostname even though they have different port numbers, // because SNI information on the incoming requests is not going to include port numbers. Issuer: "https://issuer-duplicate-address.com:1234/path2", - TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: "secret2"}, + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: "secret2"}, }, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainSameIssuerAddress2)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainSameIssuerAddress2)) - - federationDomainDifferentIssuerAddress = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "differentIssuerAddressProvider", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{ + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "differentIssuerAddressFederationDomain", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ Issuer: "https://issuer-not-duplicate.com", - TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, }, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainDifferentIssuerAddress)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainDifferentIssuerAddress)) - - // Also add one with a URL that cannot be parsed to make sure that the error handling - // for the duplicate issuers and secret names are not confused by invalid URLs. - invalidIssuerURL := ":/host//path" - _, err := url.Parse(invalidIssuerURL) //nolint:staticcheck // Yes, this URL is intentionally invalid. - r.Error(err) - federationDomainWithInvalidIssuerURL = &v1alpha1.FederationDomain{ - ObjectMeta: metav1.ObjectMeta{Name: "invalidIssuerURLProvider", Namespace: namespace}, - Spec: v1alpha1.FederationDomainSpec{ + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "invalidIssuerURLFederationDomain", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ Issuer: invalidIssuerURL, - TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: "secret1"}, }, - } - r.NoError(pinnipedAPIClient.Tracker().Add(federationDomainWithInvalidIssuerURL)) - r.NoError(federationDomainInformerClient.Tracker().Add(federationDomainWithInvalidIssuerURL)) - }) - - it("calls the ProvidersSetter with the non-duplicate", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - nonDuplicateProvider, err := provider.NewFederationDomainIssuer(federationDomainDifferentIssuerAddress.Spec.Issuer) - r.NoError(err) - - r.True(providersSetter.SetProvidersWasCalled) - r.Equal( - []*provider.FederationDomainIssuer{ - nonDuplicateProvider, + }, + oidcIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithDefaultIDP(t, "https://issuer-not-duplicate.com", oidcIdentityProvider.ObjectMeta), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "fd1", Namespace: namespace, Generation: 123}, }, - providersSetter.FederationDomainsReceived, - ) - }) - - it("updates the statuses", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - - federationDomainDifferentIssuerAddress.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomainDifferentIssuerAddress.Status.Message = "Provider successfully created" - federationDomainDifferentIssuerAddress.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomainSameIssuerAddress1.Status.Status = v1alpha1.SameIssuerHostMustUseSameSecretFederationDomainStatusCondition - federationDomainSameIssuerAddress1.Status.Message = "Issuers with the same DNS hostname (address not including port) must use the same secretName: issuer-duplicate-address.com" - federationDomainSameIssuerAddress1.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomainSameIssuerAddress2.Status.Status = v1alpha1.SameIssuerHostMustUseSameSecretFederationDomainStatusCondition - federationDomainSameIssuerAddress2.Status.Message = "Issuers with the same DNS hostname (address not including port) must use the same secretName: issuer-duplicate-address.com" - federationDomainSameIssuerAddress2.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - federationDomainWithInvalidIssuerURL.Status.Status = v1alpha1.InvalidFederationDomainStatusCondition - federationDomainWithInvalidIssuerURL.Status.Message = `Invalid: could not parse issuer as URL: parse ":/host//path": missing protocol scheme` - federationDomainWithInvalidIssuerURL.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) - - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomainSameIssuerAddress1.Namespace, - federationDomainSameIssuerAddress1.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainSameIssuerAddress1.Namespace, - federationDomainSameIssuerAddress1, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainSameIssuerAddress2.Namespace, - federationDomainSameIssuerAddress2.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainSameIssuerAddress2.Namespace, - federationDomainSameIssuerAddress2, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDifferentIssuerAddress.Namespace, - federationDomainDifferentIssuerAddress.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainDifferentIssuerAddress.Namespace, - federationDomainDifferentIssuerAddress, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainWithInvalidIssuerURL.Namespace, - federationDomainWithInvalidIssuerURL.Name, - ), - coretesting.NewUpdateSubresourceAction( - federationDomainGVR, - "status", - federationDomainWithInvalidIssuerURL.Namespace, - federationDomainWithInvalidIssuerURL, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - - when("we cannot talk to the API", func() { - var count int - it.Before(func() { - pinnipedAPIClient.PrependReactor( - "get", - "federationdomains", - func(_ coretesting.Action) (bool, runtime.Object, error) { - count++ - return true, nil, fmt.Errorf("some get error %d", count) + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess("https://iSSueR-duPlicAte-adDress.cOm/path1", oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadOneTLSSecretPerIssuerHostnameCondition(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "fd2", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess("https://issuer-duplicate-address.com:1234/path2", oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + sadOneTLSSecretPerIssuerHostnameCondition(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "invalidIssuerURLFederationDomain", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(invalidIssuerURL, oidcIdentityProvider.Name, frozenMetav1Now, 123), + []metav1.Condition{ + unknownIssuerIsUniqueCondition(frozenMetav1Now, 123), + sadIssuerURLValidConditionCannotParse(frozenMetav1Now, 123), + unknownOneTLSSecretPerIssuerHostnameCondition(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "differentIssuerAddressFederationDomain", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsLegacyConfigurationSuccess("https://issuer-not-duplicate.com", oidcIdentityProvider.Name, frozenMetav1Now, 123), + ), + }, + }, + { + name: "legacy config: no identity provider specified in federation domain and no identity providers found results in not found status", + inputObjects: []runtime.Object{ + federationDomain1, + federationDomain2, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, "", frozenMetav1Now, 123), + []metav1.Condition{ + sadIdentityProvidersFoundConditionLegacyConfigurationIdentityProviderNotFound(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate(federationDomain2, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(federationDomain2.Spec.Issuer, "", frozenMetav1Now, 123), + []metav1.Condition{ + sadIdentityProvidersFoundConditionLegacyConfigurationIdentityProviderNotFound(frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "legacy config: no identity provider specified in federation domain and multiple identity providers found results in not specified status", + inputObjects: []runtime.Object{ + federationDomain1, + oidcIdentityProvider, + ldapIdentityProvider, + adIdentityProvider, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate(federationDomain1, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsLegacyConfigurationSuccess(federationDomain1.Spec.Issuer, "", frozenMetav1Now, 123), + []metav1.Condition{ + sadIdentityProvidersFoundConditionIdentityProviderNotSpecified(3, frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain specifies identity providers that cannot be found", + inputObjects: []runtime.Object{ + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "cant-find-me", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: "cant-find-me-name", + }, + }, + { + DisplayName: "cant-find-me-either", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: "cant-find-me-either-name", + }, + }, + { + DisplayName: "cant-find-me-still", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "ActiveDirectoryIdentityProvider", + Name: "cant-find-me-still-name", + }, + }, }, - ) - }) + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadIdentityProvidersFoundConditionIdentityProvidersObjectRefsNotFound(here.Doc( + `cannot find resource specified by .spec.identityProviders[0].objectRef (with name "cant-find-me-name") - it("returns the get errors", func() { - expectedError := here.Doc(`[could not update status: get failed: some get error 1, could not update status: get failed: some get error 2, could not update status: get failed: some get error 3, could not update status: get failed: some get error 4]`) - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.EqualError(err, expectedError) + cannot find resource specified by .spec.identityProviders[1].objectRef (with name "cant-find-me-either-name") - federationDomainDifferentIssuerAddress.Status.Status = v1alpha1.SuccessFederationDomainStatusCondition - federationDomainDifferentIssuerAddress.Status.Message = "Provider successfully created" - federationDomainDifferentIssuerAddress.Status.LastUpdateTime = timePtr(metav1.NewTime(frozenNow)) + cannot find resource specified by .spec.identityProviders[2].objectRef (with name "cant-find-me-still-name")`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain specifies identity providers that all exist", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + ldapIdentityProvider, + adIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "can-find-me", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + }, + { + DisplayName: "can-find-me-too", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "can-find-me-three", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "ActiveDirectoryIdentityProvider", + Name: adIdentityProvider.Name, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithIDPs(t, "https://issuer1.com", + []*federationdomainproviders.FederationDomainIdentityProvider{ + { + DisplayName: "can-find-me", + UID: oidcIdentityProvider.UID, + Transforms: idtransform.NewTransformationPipeline(), + }, + { + DisplayName: "can-find-me-too", + UID: ldapIdentityProvider.UID, + Transforms: idtransform.NewTransformationPipeline(), + }, + { + DisplayName: "can-find-me-three", + UID: adIdentityProvider.UID, + Transforms: idtransform.NewTransformationPipeline(), + }, + }), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + ), + }, + }, + { + name: "the federation domain has duplicate display names for IDPs", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + ldapIdentityProvider, + adIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "duplicate1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + }, + { + DisplayName: "duplicate1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "duplicate1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "ActiveDirectoryIdentityProvider", + Name: adIdentityProvider.Name, + }, + }, + { + DisplayName: "duplicate2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "duplicate2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "ActiveDirectoryIdentityProvider", + Name: adIdentityProvider.Name, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadDisplayNamesUniqueCondition(`"duplicate1", "duplicate2"`, frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has unrecognized api group names in objectRefs", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + ldapIdentityProvider, + adIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("wrong.example.com"), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + }, + { + DisplayName: "name2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(""), // empty string is wrong + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "name3", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: nil, // nil is wrong, and gets treated like an empty string in the error condition + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "name4", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), // correct + Kind: "ActiveDirectoryIdentityProvider", + Name: adIdentityProvider.Name, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadAPIGroupSuffixCondition(`"", "", "wrong.example.com"`, frozenMetav1Now, 123), + sadIdentityProvidersFoundConditionIdentityProvidersObjectRefsNotFound(here.Doc( + `cannot find resource specified by .spec.identityProviders[0].objectRef (with name "some-oidc-idp") - expectedActions := []coretesting.Action{ - coretesting.NewGetAction( - federationDomainGVR, - federationDomainSameIssuerAddress1.Namespace, - federationDomainSameIssuerAddress1.Name, + cannot find resource specified by .spec.identityProviders[1].objectRef (with name "some-ldap-idp") + + cannot find resource specified by .spec.identityProviders[2].objectRef (with name "some-ldap-idp")`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has unrecognized kind names in objectRefs", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + ldapIdentityProvider, + adIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", // correct + Name: oidcIdentityProvider.Name, + }, + }, + { + DisplayName: "name2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "wrong", + Name: ldapIdentityProvider.Name, + }, + }, + { + DisplayName: "name3", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "", // empty is also wrong + Name: ldapIdentityProvider.Name, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadKindCondition(`"", "wrong"`, frozenMetav1Now, 123), + sadIdentityProvidersFoundConditionIdentityProvidersObjectRefsNotFound(here.Doc( + `cannot find resource specified by .spec.identityProviders[1].objectRef (with name "some-ldap-idp") + + cannot find resource specified by .spec.identityProviders[2].objectRef (with name "some-ldap-idp")`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has transformation expressions which don't compile", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: "this is not a valid cel expression"}, + {Type: "groups/v1", Expression: "this is also not a valid cel expression"}, + {Type: "username/v1", Expression: "username"}, // valid + {Type: "policy/v1", Expression: "still not a valid cel expression"}, + }, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadTransformationExpressionsCondition(here.Doc( + `spec.identityProvider[0].transforms.expressions[0].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'is' expecting + | this is not a valid cel expression + | .....^ + + spec.identityProvider[0].transforms.expressions[1].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'is' expecting + | this is also not a valid cel expression + | .....^ + + spec.identityProvider[0].transforms.expressions[3].expression was invalid: + CEL expression compile error: ERROR: :1:7: Syntax error: mismatched input 'not' expecting + | still not a valid cel expression + | ......^`, + ), frozenMetav1Now, 123), + sadTransformationExamplesCondition( + "unable to check if the examples specified by .spec.identityProviders[0].transforms.examples[] had errors because an expression was invalid", + frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has transformation examples which don't pass", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "policy/v1", Expression: `username == "ryan" || username == "rejectMeWithDefaultMessage"`, Message: "only ryan allowed"}, + {Type: "policy/v1", Expression: `username != "rejectMeWithDefaultMessage"`}, // no message specified + {Type: "username/v1", Expression: `"pre:" + username`}, + {Type: "groups/v1", Expression: `groups.map(g, "pre:" + g)`}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { // this example should pass + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"pre:b", "pre:a", "pre:b", "pre:a"}, // order and repeats don't matter, treated like a set + Rejected: false, + }, + }, + { // this example should pass + Username: "other", + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only ryan allowed", + }, + }, + { // this example should fail because it expects the user to be rejected but the user was actually not rejected + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "this input is ignored in this case", + }, + }, + { // this example should fail because it expects the user not to be rejected but they were actually rejected + Username: "other", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:other", + Groups: []string{"pre:a", "pre:b"}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong rejection message + Username: "other", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "wrong message", + }, + }, + { // this example should pass even though it does not make any assertion about the rejection message + // because the message assertions defaults to asserting the default rejection message + Username: "rejectMeWithDefaultMessage", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + }, + }, + { // this example should fail because it expects both the wrong username and groups + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "wrong", + Groups: []string{}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong username only + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "wrong", + Groups: []string{"pre:b", "pre:a"}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong groups only + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"wrong2", "wrong1"}, + Rejected: false, + }, + }, + { // this example should fail because it does not expect anything but the auth actually was successful + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{}, + }, + }, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadTransformationExamplesCondition(here.Doc( + `.spec.identityProviders[0].transforms.examples[2] example failed: + expected: authentication to be rejected + actual: authentication was not rejected + + .spec.identityProviders[0].transforms.examples[3] example failed: + expected: authentication not to be rejected + actual: authentication was rejected with message "only ryan allowed" + + .spec.identityProviders[0].transforms.examples[4] example failed: + expected: authentication rejection message "wrong message" + actual: authentication rejection message "only ryan allowed" + + .spec.identityProviders[0].transforms.examples[6] example failed: + expected: username "wrong" + actual: username "pre:ryan" + + .spec.identityProviders[0].transforms.examples[6] example failed: + expected: groups [] + actual: groups ["pre:a", "pre:b"] + + .spec.identityProviders[0].transforms.examples[7] example failed: + expected: username "wrong" + actual: username "pre:ryan" + + .spec.identityProviders[0].transforms.examples[8] example failed: + expected: groups ["wrong1", "wrong2"] + actual: groups ["pre:a", "pre:b"] + + .spec.identityProviders[0].transforms.examples[9] example failed: + expected: username "" + actual: username "pre:ryan" + + .spec.identityProviders[0].transforms.examples[9] example failed: + expected: groups [] + actual: groups ["pre:a", "pre:b"]`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has transformation expressions that return illegal values with examples which exercise them", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `username == "ryan" ? "" : username`}, // not allowed to return an empty string as the transformed username + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { // every example which encounters an unexpected error should fail because the transformation pipeline returned an error + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{}, + }, + { // every example which encounters an unexpected error should fail because the transformation pipeline returned an error + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{}, + }, + { // this should pass + Username: "other", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "other", + Groups: []string{"a", "b"}, + Rejected: false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadTransformationExamplesCondition(here.Doc( + `.spec.identityProviders[0].transforms.examples[0] example failed: + expected: no transformation errors + actual: transformations resulted in an unexpected error "identity transformation returned an empty username, which is not allowed" + + .spec.identityProviders[0].transforms.examples[1] example failed: + expected: no transformation errors + actual: transformations resulted in an unexpected error "identity transformation returned an empty username, which is not allowed"`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has lots of errors including errors from multiple IDPs, which are all shown in the status conditions using IDP indices in the messages", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://not-unique.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "not unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: "this will not be found", + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + {Name: "foo", Type: "string", StringValue: "bar"}, + {Name: "bar", Type: "string", StringValue: "baz"}, + }, + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `username + ":suffix"`}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { // this should fail + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "this is wrong string", + Groups: []string{"this is wrong string list"}, + }, + }, + { // this should fail + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "this is also wrong string", + Groups: []string{"this is also wrong string list"}, + }, + }, + }, + }, + }, + { + DisplayName: "not unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "this is wrong", + Name: "foo", + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + {Name: "foo", Type: "string", StringValue: "bar"}, + {Name: "bar", Type: "string", StringValue: "baz"}, + }, + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `username + ":suffix"`}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { // this should pass + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "ryan:suffix", + Groups: []string{"a", "b"}, + }, + }, + { // this should fail + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "this is still wrong string", + Groups: []string{"this is still wrong string list"}, + }, + }, + }, + }, + }, + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("this is wrong"), + Kind: "OIDCIdentityProvider", + Name: "foo", + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `username`}, + {Type: "username/v1", Expression: `this does not compile`}, + {Type: "username/v1", Expression: `username`}, + {Type: "username/v1", Expression: `this also does not compile`}, + }, + }, + }, + }, + }, + }, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config2", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://not-unique.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `username`}, + {Type: "username/v1", Expression: `this still does not compile`}, + {Type: "username/v1", Expression: `username`}, + {Type: "username/v1", Expression: `this really does not compile`}, + }, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{}, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://not-unique.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadAPIGroupSuffixCondition(`"this is wrong"`, frozenMetav1Now, 123), + sadDisplayNamesUniqueCondition(`"not unique"`, frozenMetav1Now, 123), + sadIdentityProvidersFoundConditionIdentityProvidersObjectRefsNotFound(here.Doc( + `cannot find resource specified by .spec.identityProviders[0].objectRef (with name "this will not be found") + + cannot find resource specified by .spec.identityProviders[1].objectRef (with name "foo") + + cannot find resource specified by .spec.identityProviders[2].objectRef (with name "foo")`, + ), frozenMetav1Now, 123), + sadIssuerIsUniqueCondition(frozenMetav1Now, 123), + sadKindCondition(`"this is wrong"`, frozenMetav1Now, 123), + sadTransformationExpressionsCondition(here.Doc( + `spec.identityProvider[2].transforms.expressions[1].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'does' expecting + | this does not compile + | .....^ + + spec.identityProvider[2].transforms.expressions[3].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'also' expecting + | this also does not compile + | .....^`, + ), frozenMetav1Now, 123), + sadTransformationExamplesCondition(here.Doc( + `.spec.identityProviders[0].transforms.examples[0] example failed: + expected: username "this is wrong string" + actual: username "ryan:suffix" + + .spec.identityProviders[0].transforms.examples[0] example failed: + expected: groups ["this is wrong string list"] + actual: groups ["a", "b"] + + .spec.identityProviders[0].transforms.examples[1] example failed: + expected: username "this is also wrong string" + actual: username "ryan:suffix" + + .spec.identityProviders[0].transforms.examples[1] example failed: + expected: groups ["this is also wrong string list"] + actual: groups ["a", "b"] + + .spec.identityProviders[1].transforms.examples[1] example failed: + expected: username "this is still wrong string" + actual: username "ryan:suffix" + + .spec.identityProviders[1].transforms.examples[1] example failed: + expected: groups ["this is still wrong string list"] + actual: groups ["a", "b"] + + unable to check if the examples specified by .spec.identityProviders[2].transforms.examples[] had errors because an expression was invalid`, + ), frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config2", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseError, + replaceConditions( + allHappyConditionsSuccess("https://not-unique.com", frozenMetav1Now, 123), + []metav1.Condition{ + sadIssuerIsUniqueCondition(frozenMetav1Now, 123), + sadTransformationExpressionsCondition(here.Doc( + `spec.identityProvider[0].transforms.expressions[1].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'still' expecting + | this still does not compile + | .....^ + + spec.identityProvider[0].transforms.expressions[3].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'really' expecting + | this really does not compile + | .....^`, + ), frozenMetav1Now, 123), + sadTransformationExamplesCondition( + "unable to check if the examples specified by .spec.identityProviders[0].transforms.examples[] had errors because an expression was invalid", + frozenMetav1Now, 123), + sadReadyCondition(frozenMetav1Now, 123), + }), + ), + }, + }, + { + name: "the federation domain has valid IDPs and transformations and examples", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + ldapIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "policy/v1", Expression: `username == "ryan" || username == "rejectMeWithDefaultMessage"`, Message: "only ryan allowed"}, + {Type: "policy/v1", Expression: `username != "rejectMeWithDefaultMessage"`}, // no message specified + {Type: "username/v1", Expression: `"pre:" + username`}, + {Type: "groups/v1", Expression: `groups.map(g, "pre:" + g)`}, + }, + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + {Name: "str", Type: "string", StringValue: "abc"}, + {Name: "strL", Type: "stringList", StringListValue: []string{"def"}}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"pre:b", "pre:a"}, + Rejected: false, + }, + }, + { + Username: "other", + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only ryan allowed", + }, + }, + { + Username: "rejectMeWithDefaultMessage", + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + // Not specifying message is the same as expecting the default message. + }, + }, + { + Username: "rejectMeWithDefaultMessage", + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "authentication was rejected by a configured policy", // this is the default message + }, + }, + }, + }, + }, + { + DisplayName: "name2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "LDAPIdentityProvider", + Name: ldapIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: `"pre:" + username`}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"b", "a"}, + Rejected: false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + wantFDIssuers: []*federationdomainproviders.FederationDomainIssuer{ + federationDomainIssuerWithIDPs(t, "https://issuer1.com", []*federationdomainproviders.FederationDomainIdentityProvider{ + { + DisplayName: "name1", + UID: oidcIdentityProvider.UID, + Transforms: newTransformationPipeline(t, &celtransformer.TransformationConstants{ + StringConstants: map[string]string{"str": "abc"}, + StringListConstants: map[string][]string{"strL": {"def"}}, + }, + &celtransformer.AllowAuthenticationPolicy{ + Expression: `username == "ryan" || username == "rejectMeWithDefaultMessage"`, + RejectedAuthenticationMessage: "only ryan allowed", + }, + &celtransformer.AllowAuthenticationPolicy{Expression: `username != "rejectMeWithDefaultMessage"`}, + &celtransformer.UsernameTransformation{Expression: `"pre:" + username`}, + &celtransformer.GroupsTransformation{Expression: `groups.map(g, "pre:" + g)`}, ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainSameIssuerAddress2.Namespace, - federationDomainSameIssuerAddress2.Name, + }, + { + DisplayName: "name2", + UID: ldapIdentityProvider.UID, + Transforms: newTransformationPipeline(t, &celtransformer.TransformationConstants{}, + &celtransformer.UsernameTransformation{Expression: `"pre:" + username`}, ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainDifferentIssuerAddress.Namespace, - federationDomainDifferentIssuerAddress.Name, - ), - coretesting.NewGetAction( - federationDomainGVR, - federationDomainWithInvalidIssuerURL.Namespace, - federationDomainWithInvalidIssuerURL.Name, - ), - } - r.ElementsMatch(expectedActions, pinnipedAPIClient.Actions()) - }) - }) + }, + }), + }, + wantStatusUpdates: []*configv1alpha1.FederationDomain{ + expectedFederationDomainStatusUpdate( + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + }, + configv1alpha1.FederationDomainPhaseReady, + allHappyConditionsSuccess("https://issuer1.com", frozenMetav1Now, 123), + ), + }, + }, + { + name: "the federation domain specifies illegal const type, which shouldn't really happen since the CRD validates it", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "can-find-me", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + { + Type: "this is illegal", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `one of spec.identityProvider[].transforms.constants[].type is invalid: "this is illegal"`, + }, + { + name: "the federation domain specifies illegal expression type, which shouldn't really happen since the CRD validates it", + inputObjects: []runtime.Object{ + oidcIdentityProvider, + &configv1alpha1.FederationDomain{ + ObjectMeta: metav1.ObjectMeta{Name: "config1", Namespace: namespace, Generation: 123}, + Spec: configv1alpha1.FederationDomainSpec{ + Issuer: "https://issuer1.com", + IdentityProviders: []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "can-find-me", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To(apiGroupSupervisor), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "this is illegal", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: `one of spec.identityProvider[].transforms.expressions[].type is invalid: "this is illegal"`, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + federationDomainsSetter := &fakeFederationDomainsSetter{} + pinnipedAPIClient := pinnipedfake.NewSimpleClientset() + pinnipedInformerClient := pinnipedfake.NewSimpleClientset() + for _, o := range tt.inputObjects { + require.NoError(t, pinnipedAPIClient.Tracker().Add(o)) + require.NoError(t, pinnipedInformerClient.Tracker().Add(o)) + } + if tt.configClient != nil { + tt.configClient(pinnipedAPIClient) + } + pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(pinnipedInformerClient, 0) + + controller := NewFederationDomainWatcherController( + federationDomainsSetter, + apiGroupSuffix, + clocktesting.NewFakeClock(frozenNow), + pinnipedAPIClient, + pinnipedInformers.Config().V1alpha1().FederationDomains(), + pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), + pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(), + pinnipedInformers.IDP().V1alpha1().ActiveDirectoryIdentityProviders(), + controllerlib.WithInformer, + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pinnipedInformers.Start(ctx.Done()) + controllerlib.TestRunSynchronously(t, controller) + + syncCtx := controllerlib.Context{Context: ctx, Key: controllerlib.Key{Namespace: namespace, Name: "config-name"}} + + if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.wantFDIssuers != nil { + require.True(t, federationDomainsSetter.SetFederationDomainsWasCalled) + // This is ugly, but we cannot test equality on compiled identity transformations because cel.Program + // cannot be compared for equality. This converts them to a type which can be tested for equality, + // which should be good enough for the purposes of this test. + require.ElementsMatch(t, + convertToComparableType(tt.wantFDIssuers), + convertToComparableType(federationDomainsSetter.FederationDomainsReceived)) + } else { + require.False(t, federationDomainsSetter.SetFederationDomainsWasCalled) + } + + if tt.wantStatusUpdates != nil { + // This controller should only perform updates to FederationDomain statuses. + // In this controller we don't actually care about the order of the actions, since the FederationDomains + // statuses can be updated in any order. Therefore, we are sorting here so we can use require.Equal + // to make the test output easier to read. Unfortunately the timezone nested in the condition can still + // make the test failure diffs ugly sometimes, but we do want to assert about timestamps so there's not + // much we can do about those. + actualFederationDomainUpdates := getFederationDomainStatusUpdates(t, pinnipedAPIClient.Actions()) + sortFederationDomainsByName(actualFederationDomainUpdates) + sortFederationDomainsByName(tt.wantStatusUpdates) + // Use require.Equal instead of require.ElementsMatch because require.Equal prints a nice diff. + require.Equal(t, tt.wantStatusUpdates, actualFederationDomainUpdates) + } else { + require.Empty(t, pinnipedAPIClient.Actions()) + } }) - - when("there are no FederationDomains in the informer", func() { - it("keeps waiting for one", func() { - startInformersAndController() - err := controllerlib.TestSync(t, subject, *syncContext) - r.NoError(err) - r.Empty(pinnipedAPIClient.Actions()) - r.True(providersSetter.SetProvidersWasCalled) - r.Empty(providersSetter.FederationDomainsReceived) - }) - }) - }, spec.Parallel(), spec.Report(report.Terminal{})) + } +} + +type comparableFederationDomainIssuer struct { + issuer string + identityProviders []*comparableFederationDomainIdentityProvider + defaultIdentityProvider *comparableFederationDomainIdentityProvider +} + +type comparableFederationDomainIdentityProvider struct { + DisplayName string + UID types.UID + TransformsSource []interface{} +} + +func makeFederationDomainIdentityProviderComparable(fdi *federationdomainproviders.FederationDomainIdentityProvider) *comparableFederationDomainIdentityProvider { + if fdi == nil { + return nil + } + return &comparableFederationDomainIdentityProvider{ + DisplayName: fdi.DisplayName, + UID: fdi.UID, + TransformsSource: fdi.Transforms.Source(), + } +} + +func convertToComparableType(fdis []*federationdomainproviders.FederationDomainIssuer) []*comparableFederationDomainIssuer { + result := []*comparableFederationDomainIssuer{} + for _, fdi := range fdis { + comparableFDIs := make([]*comparableFederationDomainIdentityProvider, len(fdi.IdentityProviders())) + for _, idp := range fdi.IdentityProviders() { + comparableFDIs = append(comparableFDIs, makeFederationDomainIdentityProviderComparable(idp)) + } + converted := &comparableFederationDomainIssuer{ + issuer: fdi.Issuer(), + identityProviders: comparableFDIs, + defaultIdentityProvider: makeFederationDomainIdentityProviderComparable(fdi.DefaultIdentityProvider()), + } + result = append(result, converted) + } + return result +} + +func expectedFederationDomainStatusUpdate( + fd *configv1alpha1.FederationDomain, + phase configv1alpha1.FederationDomainPhase, + conditions []metav1.Condition, +) *configv1alpha1.FederationDomain { + fdCopy := fd.DeepCopy() + + // We don't care about the spec of a FederationDomain in an update status action, + // so clear it out to make it easier to write expected values. + fdCopy.Spec = configv1alpha1.FederationDomainSpec{} + + fdCopy.Status.Phase = phase + fdCopy.Status.Conditions = conditions + + return fdCopy +} + +func getFederationDomainStatusUpdates(t *testing.T, actions []coretesting.Action) []*configv1alpha1.FederationDomain { + federationDomains := []*configv1alpha1.FederationDomain{} + + for _, action := range actions { + updateAction, ok := action.(coretesting.UpdateAction) + require.True(t, ok, "failed to cast an action as an coretesting.UpdateAction: %#v", action) + require.Equal(t, federationDomainGVR, updateAction.GetResource(), "an update action should have updated a FederationDomain but updated something else") + require.Equal(t, "status", updateAction.GetSubresource(), "an update action should have updated the status subresource but updated something else") + + fd, ok := updateAction.GetObject().(*configv1alpha1.FederationDomain) + require.True(t, ok, "failed to cast an action's object as a FederationDomain: %#v", updateAction.GetObject()) + require.Equal(t, fd.Namespace, updateAction.GetNamespace(), "an update action might have been called on the wrong namespace for a FederationDomain") + + // We don't care about the spec of a FederationDomain in an update status action, + // so clear it out to make it easier to write expected values. + copyOfFD := fd.DeepCopy() + copyOfFD.Spec = configv1alpha1.FederationDomainSpec{} + + federationDomains = append(federationDomains, copyOfFD) + } + + return federationDomains +} + +func sortFederationDomainsByName(federationDomains []*configv1alpha1.FederationDomain) { + sort.SliceStable(federationDomains, func(a, b int) bool { + return federationDomains[a].GetName() < federationDomains[b].GetName() + }) +} + +func newTransformationPipeline( + t *testing.T, + consts *celtransformer.TransformationConstants, + transformations ...celtransformer.CELTransformation, +) *idtransform.TransformationPipeline { + pipeline := idtransform.NewTransformationPipeline() + + compiler, err := celtransformer.NewCELTransformer(celTransformerMaxExpressionRuntime) + require.NoError(t, err) + + if consts.StringConstants == nil { + consts.StringConstants = map[string]string{} + } + if consts.StringListConstants == nil { + consts.StringListConstants = map[string][]string{} + } + + for _, transform := range transformations { + compiledTransform, err := compiler.CompileTransformation(transform, consts) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform) + } + + return pipeline +} + +func TestTransformationPipelinesCanBeTestedForEqualityUsingSourceToMakeTestingEasier(t *testing.T) { + compiler, err := celtransformer.NewCELTransformer(5 * time.Second) + require.NoError(t, err) + + transforms := []celtransformer.CELTransformation{ + &celtransformer.AllowAuthenticationPolicy{ + Expression: `username == "ryan" || username == "rejectMeWithDefaultMessage"`, + RejectedAuthenticationMessage: "only ryan allowed", + }, + &celtransformer.UsernameTransformation{Expression: `"pre:" + username`}, + &celtransformer.GroupsTransformation{Expression: `groups.map(g, "pre:" + g)`}, + } + + differentTransforms := []celtransformer.CELTransformation{ + &celtransformer.AllowAuthenticationPolicy{ + Expression: `username == "ryan" || username == "different"`, + RejectedAuthenticationMessage: "different", + }, + &celtransformer.UsernameTransformation{Expression: `"different:" + username`}, + &celtransformer.GroupsTransformation{Expression: `groups.map(g, "different:" + g)`}, + } + + consts := &celtransformer.TransformationConstants{ + StringConstants: map[string]string{ + "foo": "bar", + "baz": "bat", + }, + StringListConstants: map[string][]string{ + "foo": {"a", "b"}, + "bar": {"c", "d"}, + }, + } + + differentConsts := &celtransformer.TransformationConstants{ + StringConstants: map[string]string{ + "foo": "barDifferent", + "baz": "bat", + }, + StringListConstants: map[string][]string{ + "foo": {"aDifferent", "b"}, + "bar": {"c", "d"}, + }, + } + + pipeline := idtransform.NewTransformationPipeline() + equalPipeline := idtransform.NewTransformationPipeline() + differentPipeline1 := idtransform.NewTransformationPipeline() + differentPipeline2 := idtransform.NewTransformationPipeline() + expectedSourceList := []interface{}{} + + for i, transform := range transforms { + // Compile and append to a pipeline. + compiledTransform1, err := compiler.CompileTransformation(transform, consts) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform1) + + // Recompile the same thing and append it to another pipeline. + // This pipeline should end up being equal to the first one. + compiledTransform2, err := compiler.CompileTransformation(transform, consts) + require.NoError(t, err) + equalPipeline.AppendTransformation(compiledTransform2) + + // Build up a test expectation value. + expectedSourceList = append(expectedSourceList, &celtransformer.CELTransformationSource{Expr: transform, Consts: consts}) + + // Compile a different expression using the same constants and append it to a different pipeline. + // This should not be equal to the other pipelines. + compiledDifferentExpressionSameConsts, err := compiler.CompileTransformation(differentTransforms[i], consts) + require.NoError(t, err) + differentPipeline1.AppendTransformation(compiledDifferentExpressionSameConsts) + + // Compile the same expression using the different constants and append it to a different pipeline. + // This should not be equal to the other pipelines. + compiledSameExpressionDifferentConsts, err := compiler.CompileTransformation(transform, differentConsts) + require.NoError(t, err) + differentPipeline2.AppendTransformation(compiledSameExpressionDifferentConsts) + } + + require.Equal(t, expectedSourceList, pipeline.Source()) + require.Equal(t, expectedSourceList, equalPipeline.Source()) + + // The source of compiled pipelines can be compared to each other in this way for testing purposes. + require.Equal(t, pipeline.Source(), equalPipeline.Source()) + require.NotEqual(t, pipeline.Source(), differentPipeline1.Source()) + require.NotEqual(t, pipeline.Source(), differentPipeline2.Source()) } diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go index aa9ce940..fe129588 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher.go @@ -20,7 +20,7 @@ import ( "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/upstreamldap" ) @@ -133,7 +133,7 @@ func (s *ldapUpstreamGenericLDAPStatus) Conditions() []metav1.Condition { // UpstreamLDAPIdentityProviderICache is a thread safe cache that holds a list of validated upstream LDAP IDP configurations. type UpstreamLDAPIdentityProviderICache interface { - SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI) + SetLDAPIdentityProviders([]upstreamprovider.UpstreamLDAPIdentityProviderI) } type ldapWatcherController struct { @@ -207,7 +207,7 @@ func (c *ldapWatcherController) Sync(ctx controllerlib.Context) error { } requeue := false - validatedUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams)) + validatedUpstreams := make([]upstreamprovider.UpstreamLDAPIdentityProviderI, 0, len(actualUpstreams)) for _, upstream := range actualUpstreams { valid, requestedRequeue := c.validateUpstream(ctx.Context, upstream) if valid != nil { @@ -226,7 +226,7 @@ func (c *ldapWatcherController) Sync(ctx controllerlib.Context) error { return nil } -func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider) (p provider.UpstreamLDAPIdentityProviderI, requeue bool) { +func (c *ldapWatcherController) validateUpstream(ctx context.Context, upstream *v1alpha1.LDAPIdentityProvider) (p upstreamprovider.UpstreamLDAPIdentityProviderI, requeue bool) { spec := upstream.Spec config := &upstreamldap.ProviderConfig{ diff --git a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go index cc8e0188..4da28673 100644 --- a/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/ldapupstreamwatcher/ldap_upstream_watcher_test.go @@ -28,8 +28,9 @@ import ( "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/endpointaddr" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/mocks/mockldapconn" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/upstreamldap" ) @@ -1138,8 +1139,8 @@ func TestLDAPUpstreamWatcherControllerSync(t *testing.T) { pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...) kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) - cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ + cache := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() + cache.SetLDAPIdentityProviders([]upstreamprovider.UpstreamLDAPIdentityProviderI{ upstreamldap.New(upstreamldap.ProviderConfig{Name: "initial-entry"}), }) diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go index fbd54d41..918f91e5 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go @@ -21,7 +21,7 @@ import ( pinnipedcontroller "go.pinniped.dev/internal/controller" "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controllerlib" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/plog" ) @@ -133,11 +133,12 @@ func (c *oidcClientWatcherController) updateStatus( ) error { updated := upstream.DeepCopy() - hadErrorCondition := conditionsutil.MergeConfigConditions(conditions, upstream.Generation, &updated.Status.Conditions, plog.New()) + hadErrorCondition := conditionsutil.MergeConfigConditions(conditions, + upstream.Generation, &updated.Status.Conditions, plog.New(), metav1.Now()) - updated.Status.Phase = v1alpha1.PhaseReady + updated.Status.Phase = v1alpha1.OIDCClientPhaseReady if hadErrorCondition { - updated.Status.Phase = v1alpha1.PhaseError + updated.Status.Phase = v1alpha1.OIDCClientPhaseError } updated.Status.TotalClientSecrets = int32(totalClientSecrets) diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index 3cfbc7e2..0f91d0d6 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -34,8 +34,8 @@ import ( "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controller/supervisorconfig/upstreamwatchers" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/net/phttp" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/upstreamoidc" ) @@ -91,10 +91,10 @@ var ( // UpstreamOIDCIdentityProviderICache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. type UpstreamOIDCIdentityProviderICache interface { - SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI) + SetOIDCIdentityProviders([]upstreamprovider.UpstreamOIDCIdentityProviderI) } -// lruValidatorCache caches the *oidc.Provider associated with a particular issuer/TLS configuration. +// lruValidatorCache caches the *coreosoidc.Provider associated with a particular issuer/TLS configuration. type lruValidatorCache struct{ cache *cache.Expiring } type lruValidatorCacheEntry struct { @@ -175,13 +175,13 @@ func (c *oidcWatcherController) Sync(ctx controllerlib.Context) error { } requeue := false - validatedUpstreams := make([]provider.UpstreamOIDCIdentityProviderI, 0, len(actualUpstreams)) + validatedUpstreams := make([]upstreamprovider.UpstreamOIDCIdentityProviderI, 0, len(actualUpstreams)) for _, upstream := range actualUpstreams { valid := c.validateUpstream(ctx, upstream) if valid == nil { requeue = true } else { - validatedUpstreams = append(validatedUpstreams, provider.UpstreamOIDCIdentityProviderI(valid)) + validatedUpstreams = append(validatedUpstreams, upstreamprovider.UpstreamOIDCIdentityProviderI(valid)) } } c.cache.SetOIDCIdentityProviders(validatedUpstreams) diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go index 7077cf57..c6a63698 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher_test.go @@ -28,7 +28,8 @@ import ( pinnipedinformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/controllerlib" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -80,8 +81,8 @@ func TestOIDCUpstreamWatcherControllerFilterSecret(t *testing.T) { pinnipedInformers := pinnipedinformers.NewSharedInformerFactory(fakePinnipedClient, 0) fakeKubeClient := fake.NewSimpleClientset() kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) - cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ + cache := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() + cache.SetOIDCIdentityProviders([]upstreamprovider.UpstreamOIDCIdentityProviderI{ &upstreamoidc.ProviderConfig{Name: "initial-entry"}, }) secretInformer := kubeInformers.Core().V1().Secrets() @@ -92,7 +93,7 @@ func TestOIDCUpstreamWatcherControllerFilterSecret(t *testing.T) { nil, pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), secretInformer, - plog.Logr(), //nolint:staticcheck // old test with no log assertions + plog.Logr(), //nolint:staticcheck // old test with no log assertions withInformer.WithInformer, ) @@ -1415,8 +1416,8 @@ oidc: issuer did not match the issuer returned by provider, expected "` + testIs fakeKubeClient := fake.NewSimpleClientset(tt.inputSecrets...) kubeInformers := informers.NewSharedInformerFactory(fakeKubeClient, 0) testLog := testlogger.NewLegacy(t) //nolint:staticcheck // old test with lots of log statements - cache := provider.NewDynamicUpstreamIDPProvider() - cache.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ + cache := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() + cache.SetOIDCIdentityProviders([]upstreamprovider.UpstreamOIDCIdentityProviderI{ &upstreamoidc.ProviderConfig{Name: "initial-entry"}, }) diff --git a/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go index 1ab87787..0417463f 100644 --- a/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go +++ b/internal/controller/supervisorconfig/upstreamwatchers/upstream_watchers.go @@ -16,7 +16,7 @@ import ( "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/upstreamldap" ) @@ -365,7 +365,7 @@ func validateAndSetLDAPServerConnectivityAndSearchBase( return ldapConnectionValidCondition, searchBaseFoundCondition } -func EvaluateConditions(conditions GradatedConditions, config *upstreamldap.ProviderConfig) (provider.UpstreamLDAPIdentityProviderI, bool) { +func EvaluateConditions(conditions GradatedConditions, config *upstreamldap.ProviderConfig) (upstreamprovider.UpstreamLDAPIdentityProviderI, bool) { for _, gradatedCondition := range conditions.gradatedConditions { if gradatedCondition.condition.Status != metav1.ConditionTrue && gradatedCondition.isFatal { // Invalid provider, so do not load it into the cache. diff --git a/internal/controller/supervisorstorage/garbage_collector.go b/internal/controller/supervisorstorage/garbage_collector.go index c11aa8c3..4a736463 100644 --- a/internal/controller/supervisorstorage/garbage_collector.go +++ b/internal/controller/supervisorstorage/garbage_collector.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorstorage @@ -21,12 +21,13 @@ import ( pinnipedcontroller "go.pinniped.dev/internal/controller" "go.pinniped.dev/internal/controllerlib" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/openidconnect" "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestorage/refreshtoken" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" ) @@ -43,7 +44,7 @@ type garbageCollectorController struct { // UpstreamOIDCIdentityProviderICache is a thread safe cache that holds a list of validated upstream OIDC IDP configurations. type UpstreamOIDCIdentityProviderICache interface { - GetOIDCIdentityProviders() []provider.UpstreamOIDCIdentityProviderI + GetOIDCIdentityProviders() []upstreamprovider.UpstreamOIDCIdentityProviderI } func GarbageCollectorController( @@ -143,7 +144,7 @@ func (c *garbageCollectorController) Sync(ctx controllerlib.Context) error { // cleaning them out of etcd storage. fourHoursAgo := frozenClock.Now().Add(-4 * time.Hour) nowIsLessThanFourHoursBeyondSecretGCTime := garbageCollectAfterTime.After(fourHoursAgo) - if errors.As(revokeErr, &provider.RetryableRevocationError{}) && nowIsLessThanFourHoursBeyondSecretGCTime { + if errors.As(revokeErr, &dynamicupstreamprovider.RetryableRevocationError{}) && nowIsLessThanFourHoursBeyondSecretGCTime { // Hasn't been very long since secret expired, so skip deletion to try revocation again later. plog.Trace("garbage collector keeping Secret to retry upstream OIDC token revocation later", logKV(secret)...) continue @@ -244,7 +245,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont } // Try to find the provider that was originally used to create the stored session. - var foundOIDCIdentityProviderI provider.UpstreamOIDCIdentityProviderI + var foundOIDCIdentityProviderI upstreamprovider.UpstreamOIDCIdentityProviderI for _, p := range c.idpCache.GetOIDCIdentityProviders() { if p.GetName() == customSessionData.ProviderName && p.GetResourceUID() == customSessionData.ProviderUID { foundOIDCIdentityProviderI = p @@ -260,7 +261,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont upstreamAccessToken := customSessionData.OIDC.UpstreamAccessToken if upstreamRefreshToken != "" { - err := foundOIDCIdentityProviderI.RevokeToken(ctx, upstreamRefreshToken, provider.RefreshTokenType) + err := foundOIDCIdentityProviderI.RevokeToken(ctx, upstreamRefreshToken, upstreamprovider.RefreshTokenType) if err != nil { return err } @@ -268,7 +269,7 @@ func (c *garbageCollectorController) tryRevokeUpstreamOIDCToken(ctx context.Cont } if upstreamAccessToken != "" { - err := foundOIDCIdentityProviderI.RevokeToken(ctx, upstreamAccessToken, provider.AccessTokenType) + err := foundOIDCIdentityProviderI.RevokeToken(ctx, upstreamAccessToken, upstreamprovider.AccessTokenType) if err != nil { return err } diff --git a/internal/controller/supervisorstorage/garbage_collector_test.go b/internal/controller/supervisorstorage/garbage_collector_test.go index d845e558..79e1c8c0 100644 --- a/internal/controller/supervisorstorage/garbage_collector_test.go +++ b/internal/controller/supervisorstorage/garbage_collector_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package supervisorstorage @@ -25,11 +25,12 @@ import ( clocktesting "k8s.io/utils/clock/testing" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/federationdomain/clientregistry" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/refreshtoken" - "go.pinniped.dev/internal/oidc/clientregistry" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -137,7 +138,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { // Defer starting the informers until the last possible moment so that the // nested Before's can keep adding things to the informer caches. - var startInformersAndController = func(idpCache provider.DynamicUpstreamIDPProvider) { + var startInformersAndController = func(idpCache dynamicupstreamprovider.DynamicUpstreamIDPProvider) { // Set this at the last second to allow for injection of server override. subject = GarbageCollectorController( idpCache, @@ -263,7 +264,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired authcode secrets which contain upstream refresh tokens", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -308,7 +309,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret)) inactiveOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: false, Request: &fosite.Request{ ID: "request-id-2", @@ -360,7 +361,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is only revoked for the active authcode session. @@ -369,7 +370,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -387,7 +388,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired authcode secrets which contain upstream access tokens", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -432,7 +433,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(activeOIDCAuthcodeSessionSecret)) inactiveOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: false, Request: &fosite.Request{ ID: "request-id-2", @@ -484,7 +485,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is only revoked for the active authcode session. @@ -493,7 +494,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-access-token", - TokenType: provider.AccessTokenType, + TokenType: upstreamprovider.AccessTokenType, }, ) @@ -511,7 +512,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is an invalid, expired authcode secret", func() { it.Before(func() { invalidOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "", // it is invalid for there to be a missing request ID @@ -561,7 +562,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Nothing to revoke since we couldn't read the invalid secret. @@ -580,7 +581,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, expired authcode secret but its upstream name does not match any existing upstream", func() { it.Before(func() { wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -632,7 +633,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Nothing to revoke since we couldn't find the upstream in the cache. @@ -651,7 +652,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, expired authcode secret but its upstream UID does not match any existing upstream", func() { it.Before(func() { wrongProviderNameOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -703,7 +704,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Nothing to revoke since we couldn't find the upstream in the cache. @@ -722,7 +723,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, recently expired authcode secret but the upstream revocation fails", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -773,10 +774,10 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithName("upstream-oidc-provider-name"). WithResourceUID("upstream-oidc-provider-uid"). // make the upstream revocation fail in a retryable way - WithRevokeTokenError(provider.NewRetryableRevocationError(errors.New("some retryable upstream revocation error"))) + WithRevokeTokenError(dynamicupstreamprovider.NewRetryableRevocationError(errors.New("some retryable upstream revocation error"))) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Tried to revoke it, although this revocation will fail. @@ -785,7 +786,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -801,7 +802,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(errors.New("some upstream revocation error not worth retrying")) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Tried to revoke it, although this revocation will fail. @@ -810,7 +811,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -827,7 +828,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there is a valid, long-since expired authcode secret but the upstream revocation fails", func() { it.Before(func() { activeOIDCAuthcodeSession := &authorizationcode.Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "request-id-1", @@ -880,7 +881,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(errors.New("some upstream revocation error")) // the upstream revocation will fail idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // Tried to revoke it, although this revocation will fail. @@ -889,7 +890,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -906,7 +907,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired access token secrets which contain upstream refresh tokens", func() { it.Before(func() { offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"}, ID: "request-id-1", @@ -951,7 +952,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret)) offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2"}, ID: "request-id-2", @@ -1003,7 +1004,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is only revoked for the downstream session which had offline_access granted. @@ -1012,7 +1013,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -1030,7 +1031,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired access token secrets which contain upstream access tokens", func() { it.Before(func() { offlineAccessGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2", "offline_access"}, ID: "request-id-1", @@ -1075,7 +1076,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { r.NoError(kubeClient.Tracker().Add(offlineAccessGrantedOIDCAccessTokenSessionSecret)) offlineAccessNotGrantedOIDCAccessTokenSession := &accesstoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ GrantedScope: fosite.Arguments{"scope1", "scope2"}, ID: "request-id-2", @@ -1127,7 +1128,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is only revoked for the downstream session which had offline_access granted. @@ -1136,7 +1137,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-access-token", - TokenType: provider.AccessTokenType, + TokenType: upstreamprovider.AccessTokenType, }, ) @@ -1154,7 +1155,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired refresh secrets which contain upstream refresh tokens", func() { it.Before(func() { oidcRefreshSession := &refreshtoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, @@ -1205,7 +1206,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is revoked. @@ -1214,7 +1215,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-refresh-token", - TokenType: provider.RefreshTokenType, + TokenType: upstreamprovider.RefreshTokenType, }, ) @@ -1231,7 +1232,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { when("there are valid, expired refresh secrets which contain upstream access tokens", func() { it.Before(func() { oidcRefreshSession := &refreshtoken.Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ ID: "request-id-1", Client: &clientregistry.Client{}, @@ -1282,7 +1283,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { WithRevokeTokenError(nil) idpListerBuilder := oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyOIDCUpstream.Build()) - startInformersAndController(idpListerBuilder.Build()) + startInformersAndController(idpListerBuilder.BuildDynamicUpstreamIDPProvider()) r.NoError(controllerlib.TestSync(t, subject, *syncContext)) // The upstream refresh token is revoked. @@ -1291,7 +1292,7 @@ func TestGarbageCollectorControllerSync(t *testing.T) { &oidctestutil.RevokeTokenArgs{ Ctx: syncContext.Context, Token: "fake-upstream-access-token", - TokenType: provider.AccessTokenType, + TokenType: upstreamprovider.AccessTokenType, }, ) diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 55e02d45..6880fc2d 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package controller @@ -18,6 +18,16 @@ func NameAndNamespaceExactMatchFilterFactory(name, namespace string) controllerl }, nil) } +// MatchAnythingIgnoringUpdatesFilter returns a controllerlib.Filter that allows all objects but ignores updates. +func MatchAnythingIgnoringUpdatesFilter(parentFunc controllerlib.ParentFunc) controllerlib.Filter { + return controllerlib.FilterFuncs{ + AddFunc: func(object metav1.Object) bool { return true }, + UpdateFunc: func(oldObj, newObj metav1.Object) bool { return false }, + DeleteFunc: func(object metav1.Object) bool { return true }, + ParentFunc: parentFunc, + } +} + // MatchAnythingFilter returns a controllerlib.Filter that allows all objects. func MatchAnythingFilter(parentFunc controllerlib.ParentFunc) controllerlib.Filter { return SimpleFilter(func(object metav1.Object) bool { return true }, parentFunc) diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index 041ebe32..92dc1e1e 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package controllermanager provides an entrypoint into running all of the controllers that run as @@ -222,7 +222,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol agentConfig, client, informers.installationNamespaceK8s.Core().V1().Pods(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ), singletonWorker, ). @@ -232,7 +232,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol webhookcachefiller.New( c.AuthenticatorCache, informers.pinniped.Authentication().V1alpha1().WebhookAuthenticators(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ), singletonWorker, ). @@ -240,7 +240,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol jwtcachefiller.New( c.AuthenticatorCache, informers.pinniped.Authentication().V1alpha1().JWTAuthenticators(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ), singletonWorker, ). @@ -249,7 +249,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol c.AuthenticatorCache, informers.pinniped.Authentication().V1alpha1().WebhookAuthenticators(), informers.pinniped.Authentication().V1alpha1().JWTAuthenticators(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ), singletonWorker, ). @@ -275,7 +275,7 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol impersonator.New, c.NamesConfig.ImpersonationSignerSecret, c.ImpersonationSigningCertProvider, - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements ), singletonWorker, ). diff --git a/internal/oidc/clientregistry/clientregistry.go b/internal/federationdomain/clientregistry/clientregistry.go similarity index 98% rename from internal/oidc/clientregistry/clientregistry.go rename to internal/federationdomain/clientregistry/clientregistry.go index e1d87abb..7dfa7d9f 100644 --- a/internal/oidc/clientregistry/clientregistry.go +++ b/internal/federationdomain/clientregistry/clientregistry.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package clientregistry defines Pinniped's OAuth2/OIDC clients. @@ -18,7 +18,7 @@ import ( configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" supervisorclient "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/plog" ) diff --git a/internal/oidc/clientregistry/clientregistry_test.go b/internal/federationdomain/clientregistry/clientregistry_test.go similarity index 99% rename from internal/oidc/clientregistry/clientregistry_test.go rename to internal/federationdomain/clientregistry/clientregistry_test.go index 03651421..4c33dfcc 100644 --- a/internal/oidc/clientregistry/clientregistry_test.go +++ b/internal/federationdomain/clientregistry/clientregistry_test.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package clientregistry @@ -21,7 +21,7 @@ import ( configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/testutil" ) diff --git a/internal/oidc/provider/csp/csp.go b/internal/federationdomain/csp/csp.go similarity index 81% rename from internal/oidc/provider/csp/csp.go rename to internal/federationdomain/csp/csp.go index d3f97e50..8487ca0d 100644 --- a/internal/oidc/provider/csp/csp.go +++ b/internal/federationdomain/csp/csp.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package csp defines helpers related to HTML Content Security Policies. diff --git a/internal/oidc/provider/csp/csp_test.go b/internal/federationdomain/csp/csp_test.go similarity index 81% rename from internal/oidc/provider/csp/csp_test.go rename to internal/federationdomain/csp/csp_test.go index 746d5822..5fa50697 100644 --- a/internal/oidc/provider/csp/csp_test.go +++ b/internal/federationdomain/csp/csp_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package csp diff --git a/internal/oidc/csrftoken/csrftoken.go b/internal/federationdomain/csrftoken/csrftoken.go similarity index 87% rename from internal/oidc/csrftoken/csrftoken.go rename to internal/federationdomain/csrftoken/csrftoken.go index bc7b713c..c1d79fe8 100644 --- a/internal/oidc/csrftoken/csrftoken.go +++ b/internal/federationdomain/csrftoken/csrftoken.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package csrftoken diff --git a/internal/oidc/csrftoken/csrftoken_test.go b/internal/federationdomain/csrftoken/csrftoken_test.go similarity index 84% rename from internal/oidc/csrftoken/csrftoken_test.go rename to internal/federationdomain/csrftoken/csrftoken_test.go index 61030731..3c8f6e1e 100644 --- a/internal/oidc/csrftoken/csrftoken_test.go +++ b/internal/federationdomain/csrftoken/csrftoken_test.go @@ -1,4 +1,4 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package csrftoken diff --git a/internal/oidc/downstreamsession/downstream_session.go b/internal/federationdomain/downstreamsession/downstream_session.go similarity index 79% rename from internal/oidc/downstreamsession/downstream_session.go rename to internal/federationdomain/downstreamsession/downstream_session.go index 11f6c340..3452fd78 100644 --- a/internal/oidc/downstreamsession/downstream_session.go +++ b/internal/federationdomain/downstreamsession/downstream_session.go @@ -5,6 +5,7 @@ package downstreamsession import ( + "context" "errors" "fmt" "net/url" @@ -19,8 +20,9 @@ import ( oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -38,6 +40,9 @@ const ( requiredClaimEmptyErr = constable.Error("required claim in upstream ID token is empty") emailVerifiedClaimInvalidFormatErr = constable.Error("email_verified claim in upstream ID token has invalid format") emailVerifiedClaimFalseErr = constable.Error("email_verified claim in upstream ID token has false value") + idTransformUnexpectedErr = constable.Error("configured identity transformation or policy resulted in unexpected error") + + idpNameSubjectQueryParam = "idpName" ) // MakeDownstreamSession creates a downstream OIDC session. @@ -82,16 +87,20 @@ func MakeDownstreamSession( } func MakeDownstreamLDAPOrADCustomSessionData( - ldapUpstream provider.UpstreamLDAPIdentityProviderI, + ldapUpstream upstreamprovider.UpstreamLDAPIdentityProviderI, idpType psession.ProviderType, authenticateResponse *authenticators.Response, username string, + untransformedUpstreamUsername string, + untransformedUpstreamGroups []string, ) *psession.CustomSessionData { customSessionData := &psession.CustomSessionData{ - Username: username, - ProviderUID: ldapUpstream.GetResourceUID(), - ProviderName: ldapUpstream.GetName(), - ProviderType: idpType, + Username: username, + UpstreamUsername: untransformedUpstreamUsername, + UpstreamGroups: untransformedUpstreamGroups, + ProviderUID: ldapUpstream.GetResourceUID(), + ProviderName: ldapUpstream.GetName(), + ProviderType: idpType, } if idpType == psession.ProviderTypeLDAP { @@ -112,9 +121,11 @@ func MakeDownstreamLDAPOrADCustomSessionData( } func MakeDownstreamOIDCCustomSessionData( - oidcUpstream provider.UpstreamOIDCIdentityProviderI, + oidcUpstream upstreamprovider.UpstreamOIDCIdentityProviderI, token *oidctypes.Token, username string, + untransformedUpstreamUsername string, + untransformedUpstreamGroups []string, ) (*psession.CustomSessionData, error) { upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, oidcUpstream.GetName(), token.IDToken.Claims) if err != nil { @@ -126,10 +137,12 @@ func MakeDownstreamOIDCCustomSessionData( } customSessionData := &psession.CustomSessionData{ - Username: username, - ProviderUID: oidcUpstream.GetResourceUID(), - ProviderName: oidcUpstream.GetName(), - ProviderType: psession.ProviderTypeOIDC, + Username: username, + UpstreamUsername: untransformedUpstreamUsername, + UpstreamGroups: untransformedUpstreamGroups, + ProviderUID: oidcUpstream.GetResourceUID(), + ProviderName: oidcUpstream.GetName(), + ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamIssuer: upstreamIssuer, UpstreamSubject: upstreamSubject, @@ -200,10 +213,11 @@ func AutoApproveScopes(authorizeRequester fosite.AuthorizeRequester) { // GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order. func GetDownstreamIdentityFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + upstreamIDPConfig upstreamprovider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, + idpDisplayName string, ) (string, string, []string, error) { - subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims) + subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims, idpDisplayName) if err != nil { return "", "", nil, err } @@ -218,7 +232,7 @@ func GetDownstreamIdentityFromUpstreamIDToken( // MapAdditionalClaimsFromUpstreamIDToken returns the additionalClaims mapped from the upstream token, if any. func MapAdditionalClaimsFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + upstreamIDPConfig upstreamprovider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, ) map[string]interface{} { mapped := make(map[string]interface{}, len(upstreamIDPConfig.GetAdditionalClaimMappings())) @@ -237,9 +251,34 @@ func MapAdditionalClaimsFromUpstreamIDToken( return mapped } +func ApplyIdentityTransformations( + ctx context.Context, + identityTransforms *idtransform.TransformationPipeline, + username string, + groups []string, +) (string, []string, error) { + transformationResult, err := identityTransforms.Evaluate(ctx, username, groups) + if err != nil { + plog.Error("unexpected identity transformation error during authentication", err, "inputUsername", username) + return "", nil, idTransformUnexpectedErr + } + if !transformationResult.AuthenticationAllowed { + plog.Debug("authentication rejected by configured policy", "inputUsername", username, "inputGroups", groups) + return "", nil, fmt.Errorf("configured identity policy rejected this authentication: %s", transformationResult.RejectedAuthenticationMessage) + } + plog.Debug("identity transformation successfully applied during authentication", + "originalUsername", username, + "newUsername", transformationResult.Username, + "originalGroups", groups, + "newGroups", transformationResult.Groups, + ) + return transformationResult.Username, transformationResult.Groups, nil +} + func getSubjectAndUsernameFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + upstreamIDPConfig upstreamprovider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, + idpDisplayName string, ) (string, string, error) { // The spec says the "sub" claim is only unique per issuer, // so we will prepend the issuer string to make it globally unique. @@ -251,11 +290,11 @@ func getSubjectAndUsernameFromUpstreamIDToken( if err != nil { return "", "", err } - subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuer, upstreamSubject) + subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuer, upstreamSubject, idpDisplayName) usernameClaimName := upstreamIDPConfig.GetUsernameClaim() if usernameClaimName == "" { - return subject, subject, nil + return subject, downstreamUsernameFromUpstreamOIDCSubject(upstreamIssuer, upstreamSubject), nil } // If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified" @@ -323,27 +362,41 @@ func ExtractStringClaimValue(claimName string, upstreamIDPName string, idTokenCl return valueAsString, nil } -func DownstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticators.Response) string { +func DownstreamSubjectFromUpstreamLDAP( + ldapUpstream upstreamprovider.UpstreamLDAPIdentityProviderI, + authenticateResponse *authenticators.Response, + idpDisplayName string, +) string { ldapURL := *ldapUpstream.GetURL() - return DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL) + return DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL, idpDisplayName) } -func DownstreamLDAPSubject(uid string, ldapURL url.URL) string { +func DownstreamLDAPSubject(uid string, ldapURL url.URL, idpDisplayName string) string { q := ldapURL.Query() + q.Set(idpNameSubjectQueryParam, idpDisplayName) q.Set(oidcapi.IDTokenClaimSubject, uid) ldapURL.RawQuery = q.Encode() return ldapURL.String() } -func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string { - return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject)) +func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string, idpDisplayName string) string { + return fmt.Sprintf("%s?%s=%s&%s=%s", upstreamIssuerAsString, + idpNameSubjectQueryParam, url.QueryEscape(idpDisplayName), + oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject), + ) +} + +func downstreamUsernameFromUpstreamOIDCSubject(upstreamIssuerAsString string, upstreamSubject string) string { + return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, + oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject), + ) } // GetGroupsFromUpstreamIDToken returns mapped group names coerced into a slice of strings. // It returns nil when there is no configured groups claim name, or then when the configured claim name is not found // in the provided map of claims. It returns an error when the claim exists but its value cannot be parsed. func GetGroupsFromUpstreamIDToken( - upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI, + upstreamIDPConfig upstreamprovider.UpstreamOIDCIdentityProviderI, idTokenClaims map[string]interface{}, ) ([]string, error) { groupsClaimName := upstreamIDPConfig.GetGroupsClaim() diff --git a/internal/federationdomain/downstreamsession/downstream_session_test.go b/internal/federationdomain/downstreamsession/downstream_session_test.go new file mode 100644 index 00000000..048c21b1 --- /dev/null +++ b/internal/federationdomain/downstreamsession/downstream_session_test.go @@ -0,0 +1,274 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package downstreamsession + +import ( + "context" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/celtransformer" + "go.pinniped.dev/internal/idtransform" + "go.pinniped.dev/internal/testutil/oidctestutil" +) + +func TestMapAdditionalClaimsFromUpstreamIDToken(t *testing.T) { + tests := []struct { + name string + additionalClaimMappings map[string]string + upstreamClaims map[string]interface{} + wantClaims map[string]interface{} + }{ + { + name: "happy path", + additionalClaimMappings: map[string]string{ + "email": "notification_email", + }, + upstreamClaims: map[string]interface{}{ + "notification_email": "test@example.com", + }, + wantClaims: map[string]interface{}{ + "email": "test@example.com", + }, + }, + { + name: "missing", + additionalClaimMappings: map[string]string{ + "email": "email", + }, + upstreamClaims: map[string]interface{}{}, + wantClaims: map[string]interface{}{}, + }, + { + name: "complex", + additionalClaimMappings: map[string]string{ + "complex": "complex", + }, + upstreamClaims: map[string]interface{}{ + "complex": map[string]string{ + "subClaim": "subValue", + }, + }, + wantClaims: map[string]interface{}{ + "complex": map[string]string{ + "subClaim": "subValue", + }, + }, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + idp := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithAdditionalClaimMappings(test.additionalClaimMappings). + Build() + actual := MapAdditionalClaimsFromUpstreamIDToken(idp, test.upstreamClaims) + + require.Equal(t, test.wantClaims, actual) + }) + } +} + +func TestApplyIdentityTransformations(t *testing.T) { + tests := []struct { + name string + transforms []celtransformer.CELTransformation + username string + groups []string + wantUsername string + wantGroups []string + wantErr string + }{ + { + name: "unexpected errors", + transforms: []celtransformer.CELTransformation{ + &celtransformer.UsernameTransformation{Expression: `""`}, + }, + username: "ryan", + groups: []string{"a", "b"}, + wantErr: "configured identity transformation or policy resulted in unexpected error", + }, + { + name: "auth disallowed by policy with implicit rejection message", + transforms: []celtransformer.CELTransformation{ + &celtransformer.AllowAuthenticationPolicy{Expression: `false`}, + }, + username: "ryan", + groups: []string{"a", "b"}, + wantErr: "configured identity policy rejected this authentication: authentication was rejected by a configured policy", + }, + { + name: "auth disallowed by policy with explicit rejection message", + transforms: []celtransformer.CELTransformation{ + &celtransformer.AllowAuthenticationPolicy{ + Expression: `false`, + RejectedAuthenticationMessage: "this is the stated reason", + }, + }, + username: "ryan", + groups: []string{"a", "b"}, + wantErr: "configured identity policy rejected this authentication: this is the stated reason", + }, + { + name: "successful auth", + transforms: []celtransformer.CELTransformation{ + &celtransformer.UsernameTransformation{Expression: `"pre:" + username`}, + &celtransformer.GroupsTransformation{Expression: `groups.map(g, "pre:" + g)`}, + }, + username: "ryan", + groups: []string{"a", "b"}, + wantUsername: "pre:ryan", + wantGroups: []string{"pre:a", "pre:b"}, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + transformer, err := celtransformer.NewCELTransformer(5 * time.Second) + require.NoError(t, err) + + pipeline := idtransform.NewTransformationPipeline() + for _, transform := range tt.transforms { + compiledTransform, err := transformer.CompileTransformation(transform, nil) + require.NoError(t, err) + pipeline.AppendTransformation(compiledTransform) + } + + gotUsername, gotGroups, err := ApplyIdentityTransformations(context.Background(), pipeline, tt.username, tt.groups) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + require.Empty(t, gotUsername) + require.Nil(t, gotGroups) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantUsername, gotUsername) + require.Equal(t, tt.wantGroups, gotGroups) + } + }) + } +} + +func TestDownstreamLDAPSubject(t *testing.T) { + tests := []struct { + name string + uid string + ldapURL string + idpDisplayName string + wantSubject string + }{ + { + name: "simple display name", + uid: "some uid", + ldapURL: "ldaps://server.example.com:1234", + idpDisplayName: "simpleName", + wantSubject: "ldaps://server.example.com:1234?idpName=simpleName&sub=some+uid", + }, + { + name: "interesting display name", + uid: "some uid", + ldapURL: "ldaps://server.example.com:1234", + idpDisplayName: "this is a 👍 display name that 🦭 can handle", + wantSubject: "ldaps://server.example.com:1234?idpName=this+is+a+%F0%9F%91%8D+display+name+that+%F0%9F%A6%AD+can+handle&sub=some+uid", + }, + { + name: "url already has query", + uid: "some uid", + ldapURL: "ldaps://server.example.com:1234?a=1&b=%F0%9F%A6%AD", + idpDisplayName: "some name", + wantSubject: "ldaps://server.example.com:1234?a=1&b=%F0%9F%A6%AD&idpName=some+name&sub=some+uid", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + url, err := url.Parse(test.ldapURL) + require.NoError(t, err) + + actual := DownstreamLDAPSubject(test.uid, *url, test.idpDisplayName) + + require.Equal(t, test.wantSubject, actual) + }) + } +} + +func TestDownstreamSubjectFromUpstreamOIDC(t *testing.T) { + tests := []struct { + name string + upstreamIssuerAsString string + upstreamSubject string + idpDisplayName string + wantSubject string + }{ + { + name: "simple display name", + upstreamIssuerAsString: "https://server.example.com:1234/path", + upstreamSubject: "some subject", + idpDisplayName: "simpleName", + wantSubject: "https://server.example.com:1234/path?idpName=simpleName&sub=some+subject", + }, + { + name: "interesting display name", + upstreamIssuerAsString: "https://server.example.com:1234/path", + upstreamSubject: "some subject", + idpDisplayName: "this is a 👍 display name that 🦭 can handle", + wantSubject: "https://server.example.com:1234/path?idpName=this+is+a+%F0%9F%91%8D+display+name+that+%F0%9F%A6%AD+can+handle&sub=some+subject", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual := downstreamSubjectFromUpstreamOIDC(test.upstreamIssuerAsString, test.upstreamSubject, test.idpDisplayName) + + require.Equal(t, test.wantSubject, actual) + }) + } +} + +func TestDownstreamUsernameFromUpstreamOIDCSubject(t *testing.T) { + tests := []struct { + name string + upstreamIssuerAsString string + upstreamSubject string + wantSubject string + }{ + { + name: "simple upstreamSubject", + upstreamIssuerAsString: "https://server.example.com:1234/path", + upstreamSubject: "some subject", + wantSubject: "https://server.example.com:1234/path?sub=some+subject", + }, + { + name: "interesting upstreamSubject", + upstreamIssuerAsString: "https://server.example.com:1234/path", + upstreamSubject: "this is a 👍 subject that 🦭 can handle", + wantSubject: "https://server.example.com:1234/path?sub=this+is+a+%F0%9F%91%8D+subject+that+%F0%9F%A6%AD+can+handle", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + actual := downstreamUsernameFromUpstreamOIDCSubject(test.upstreamIssuerAsString, test.upstreamSubject) + + require.Equal(t, test.wantSubject, actual) + }) + } +} diff --git a/internal/oidc/dynamiccodec/codec.go b/internal/federationdomain/dynamiccodec/codec.go similarity index 93% rename from internal/oidc/dynamiccodec/codec.go rename to internal/federationdomain/dynamiccodec/codec.go index 5168b2b7..36e9a8e9 100644 --- a/internal/oidc/dynamiccodec/codec.go +++ b/internal/federationdomain/dynamiccodec/codec.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package dynamiccodec provides a type that can encode information using a just-in-time signing and @@ -10,7 +10,7 @@ import ( "github.com/gorilla/securecookie" - "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/federationdomain/oidc" ) var _ oidc.Codec = &Codec{} diff --git a/internal/oidc/dynamiccodec/codec_test.go b/internal/federationdomain/dynamiccodec/codec_test.go similarity index 98% rename from internal/oidc/dynamiccodec/codec_test.go rename to internal/federationdomain/dynamiccodec/codec_test.go index bcff2d03..d6c85357 100644 --- a/internal/oidc/dynamiccodec/codec_test.go +++ b/internal/federationdomain/dynamiccodec/codec_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package dynamiccodec diff --git a/internal/oidc/provider/dynamic_tls_cert_provider.go b/internal/federationdomain/dynamictlscertprovider/dynamic_tls_cert_provider.go similarity index 93% rename from internal/oidc/provider/dynamic_tls_cert_provider.go rename to internal/federationdomain/dynamictlscertprovider/dynamic_tls_cert_provider.go index 7c48ad9c..a27f2ef4 100644 --- a/internal/oidc/provider/dynamic_tls_cert_provider.go +++ b/internal/federationdomain/dynamictlscertprovider/dynamic_tls_cert_provider.go @@ -1,7 +1,7 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package provider +package dynamictlscertprovider import ( "crypto/tls" diff --git a/internal/federationdomain/dynamicupstreamprovider/dynamic_upstream_idp_provider.go b/internal/federationdomain/dynamicupstreamprovider/dynamic_upstream_idp_provider.go new file mode 100644 index 00000000..bb92e610 --- /dev/null +++ b/internal/federationdomain/dynamicupstreamprovider/dynamic_upstream_idp_provider.go @@ -0,0 +1,87 @@ +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package dynamicupstreamprovider + +import ( + "fmt" + "sync" + + "go.pinniped.dev/internal/federationdomain/upstreamprovider" +) + +type DynamicUpstreamIDPProvider interface { + SetOIDCIdentityProviders(oidcIDPs []upstreamprovider.UpstreamOIDCIdentityProviderI) + GetOIDCIdentityProviders() []upstreamprovider.UpstreamOIDCIdentityProviderI + SetLDAPIdentityProviders(ldapIDPs []upstreamprovider.UpstreamLDAPIdentityProviderI) + GetLDAPIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI + SetActiveDirectoryIdentityProviders(adIDPs []upstreamprovider.UpstreamLDAPIdentityProviderI) + GetActiveDirectoryIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI +} + +type dynamicUpstreamIDPProvider struct { + oidcUpstreams []upstreamprovider.UpstreamOIDCIdentityProviderI + ldapUpstreams []upstreamprovider.UpstreamLDAPIdentityProviderI + activeDirectoryUpstreams []upstreamprovider.UpstreamLDAPIdentityProviderI + mutex sync.RWMutex +} + +func NewDynamicUpstreamIDPProvider() DynamicUpstreamIDPProvider { + return &dynamicUpstreamIDPProvider{ + oidcUpstreams: []upstreamprovider.UpstreamOIDCIdentityProviderI{}, + ldapUpstreams: []upstreamprovider.UpstreamLDAPIdentityProviderI{}, + activeDirectoryUpstreams: []upstreamprovider.UpstreamLDAPIdentityProviderI{}, + } +} + +func (p *dynamicUpstreamIDPProvider) SetOIDCIdentityProviders(oidcIDPs []upstreamprovider.UpstreamOIDCIdentityProviderI) { + p.mutex.Lock() // acquire a write lock + defer p.mutex.Unlock() + p.oidcUpstreams = oidcIDPs +} + +func (p *dynamicUpstreamIDPProvider) GetOIDCIdentityProviders() []upstreamprovider.UpstreamOIDCIdentityProviderI { + p.mutex.RLock() // acquire a read lock + defer p.mutex.RUnlock() + return p.oidcUpstreams +} + +func (p *dynamicUpstreamIDPProvider) SetLDAPIdentityProviders(ldapIDPs []upstreamprovider.UpstreamLDAPIdentityProviderI) { + p.mutex.Lock() // acquire a write lock + defer p.mutex.Unlock() + p.ldapUpstreams = ldapIDPs +} + +func (p *dynamicUpstreamIDPProvider) GetLDAPIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI { + p.mutex.RLock() // acquire a read lock + defer p.mutex.RUnlock() + return p.ldapUpstreams +} + +func (p *dynamicUpstreamIDPProvider) SetActiveDirectoryIdentityProviders(adIDPs []upstreamprovider.UpstreamLDAPIdentityProviderI) { + p.mutex.Lock() // acquire a write lock + defer p.mutex.Unlock() + p.activeDirectoryUpstreams = adIDPs +} + +func (p *dynamicUpstreamIDPProvider) GetActiveDirectoryIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI { + p.mutex.RLock() // acquire a read lock + defer p.mutex.RUnlock() + return p.activeDirectoryUpstreams +} + +type RetryableRevocationError struct { + wrapped error +} + +func NewRetryableRevocationError(wrapped error) RetryableRevocationError { + return RetryableRevocationError{wrapped: wrapped} +} + +func (e RetryableRevocationError) Error() string { + return fmt.Sprintf("retryable revocation error: %v", e.wrapped) +} + +func (e RetryableRevocationError) Unwrap() error { + return e.wrapped +} diff --git a/internal/oidc/auth/auth_handler.go b/internal/federationdomain/endpoints/auth/auth_handler.go similarity index 69% rename from internal/oidc/auth/auth_handler.go rename to internal/federationdomain/endpoints/auth/auth_handler.go index 772d1291..df58c543 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/federationdomain/endpoints/auth/auth_handler.go @@ -16,14 +16,17 @@ import ( "golang.org/x/oauth2" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/federationdomain/csrftoken" + "go.pinniped.dev/internal/federationdomain/downstreamsession" + "go.pinniped.dev/internal/federationdomain/endpoints/login" + "go.pinniped.dev/internal/federationdomain/federationdomainproviders" + "go.pinniped.dev/internal/federationdomain/formposthtml" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/resolvedprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/csrftoken" - "go.pinniped.dev/internal/oidc/downstreamsession" - "go.pinniped.dev/internal/oidc/login" - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidc/provider/formposthtml" + "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -37,7 +40,7 @@ const ( func NewHandler( downstreamIssuer string, - idpLister oidc.UpstreamIdentityProvidersLister, + idpFinder federationdomainproviders.FederationDomainIdentityProvidersFinderI, oauthHelperWithoutStorage fosite.OAuth2Provider, oauthHelperWithStorage fosite.OAuth2Provider, generateCSRF func() (csrftoken.CSRFToken, error), @@ -54,23 +57,60 @@ func NewHandler( return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method) } - // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and - // oidcapi.AuthorizeUpstreamIDPTypeParamName query params to request a certain upstream IDP. - // The Pinniped CLI has been sending these params since v0.9.0. - // Currently, these are ignored because the Supervisor does not yet support logins when multiple IDPs - // are configured. However, these params should be honored in the future when choosing an upstream - // here, e.g. by calling oidcapi.FindUpstreamIDPByNameAndType() when the params are present. - oidcUpstream, ldapUpstream, idpType, err := chooseUpstreamIDP(idpLister) - if err != nil { - plog.WarningErr("authorize upstream config", err) - return err + requestedBrowserlessFlow := len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || + len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 + + // Need to parse the request params so we can get the IDP name. The style and text of the error is inspired by + // fosite's implementation of NewAuthorizeRequest(). Fosite only calls ParseMultipartForm() there. However, + // although ParseMultipartForm() calls ParseForm(), it swallows errors from ParseForm() sometimes. To avoid + // having any errors swallowed, we call both. When fosite calls ParseMultipartForm() later, it will be a noop. + if err := r.ParseForm(); err != nil { + oidc.WriteAuthorizeError(r, w, + oauthHelperWithoutStorage, + fosite.NewAuthorizeRequest(), + fosite.ErrInvalidRequest. + WithHint("Unable to parse form params, make sure to send a properly formatted query params or form request body."). + WithWrap(err).WithDebug(err.Error()), + requestedBrowserlessFlow) + return nil + } + if err := r.ParseMultipartForm(1 << 20); err != nil && err != http.ErrNotMultipart { + oidc.WriteAuthorizeError(r, w, + oauthHelperWithoutStorage, + fosite.NewAuthorizeRequest(), + fosite.ErrInvalidRequest. + WithHint("Unable to parse multipart HTTP body, make sure to send a properly formatted form request body."). + WithWrap(err).WithDebug(err.Error()), + requestedBrowserlessFlow) + return nil } - if idpType == psession.ProviderTypeOIDC { - if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || - len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 { + // Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and + // oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP. + // The Pinniped CLI has been sending these params since v0.9.0. + idpNameQueryParamValue := r.Form.Get(oidcapi.AuthorizeUpstreamIDPNameParamName) + oidcUpstream, ldapUpstream, err := chooseUpstreamIDP(idpNameQueryParamValue, idpFinder) + if err != nil { + oidc.WriteAuthorizeError(r, w, + oauthHelperWithoutStorage, + fosite.NewAuthorizeRequest(), + fosite.ErrInvalidRequest. + WithHintf("%q param error: %s", oidcapi.AuthorizeUpstreamIDPNameParamName, err.Error()). + WithWrap(err).WithDebug(err.Error()), + requestedBrowserlessFlow) + return nil + } + + if oidcUpstream != nil { + if requestedBrowserlessFlow { // The client set a username header, so they are trying to log in with a username/password. - return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, oauthHelperWithStorage, oidcUpstream) + return handleAuthRequestForOIDCUpstreamPasswordGrant(r, w, + oauthHelperWithStorage, + oidcUpstream.Provider, + oidcUpstream.Transforms, + oidcUpstream.DisplayName, + idpNameQueryParamValue, + ) } return handleAuthRequestForOIDCUpstreamBrowserFlow(r, w, oauthHelperWithoutStorage, @@ -79,31 +119,31 @@ func NewHandler( downstreamIssuer, upstreamStateEncoder, cookieCodec, + idpNameQueryParamValue, ) } // We know it's an AD/LDAP upstream. - if len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0 || - len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0 { + if requestedBrowserlessFlow { // The client set a username header, so they are trying to log in with a username/password. return handleAuthRequestForLDAPUpstreamCLIFlow(r, w, oauthHelperWithStorage, - ldapUpstream, - idpType, + ldapUpstream.Provider, + ldapUpstream.SessionProviderType, + ldapUpstream.Transforms, + ldapUpstream.DisplayName, + idpNameQueryParamValue, ) } - return handleAuthRequestForLDAPUpstreamBrowserFlow( - r, - w, + return handleAuthRequestForLDAPUpstreamBrowserFlow(r, w, oauthHelperWithoutStorage, - generateCSRF, - generateNonce, - generatePKCE, + generateCSRF, generateNonce, generatePKCE, ldapUpstream, - idpType, + ldapUpstream.SessionProviderType, downstreamIssuer, upstreamStateEncoder, cookieCodec, + idpNameQueryParamValue, ) }) @@ -117,24 +157,29 @@ func handleAuthRequestForLDAPUpstreamCLIFlow( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, - ldapUpstream provider.UpstreamLDAPIdentityProviderI, + ldapUpstream upstreamprovider.UpstreamLDAPIdentityProviderI, idpType psession.ProviderType, + identityTransforms *idtransform.TransformationPipeline, + idpDisplayName string, + idpNameQueryParamValue string, ) error { authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true) if !created { return nil } + maybeLogDeprecationWarningForMissingIDPParam(idpNameQueryParamValue, authorizeRequester) + if !requireStaticClientForUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) { return nil } - username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) + submittedUsername, submittedPassword, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) if !hadUsernamePasswordValues { return nil } - authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password, authorizeRequester.GetGrantedScopes()) + authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), submittedUsername, submittedPassword, authorizeRequester.GetGrantedScopes()) if err != nil { plog.WarningErr("unexpected error during upstream LDAP authentication", err, "upstreamName", ldapUpstream.GetName()) return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication") @@ -145,10 +190,19 @@ func handleAuthRequestForLDAPUpstreamCLIFlow( return nil } - subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse) - username = authenticateResponse.User.GetName() - groups := authenticateResponse.User.GetGroups() - customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username) + subject := downstreamsession.DownstreamSubjectFromUpstreamLDAP(ldapUpstream, authenticateResponse, idpDisplayName) + upstreamUsername := authenticateResponse.User.GetName() + upstreamGroups := authenticateResponse.User.GetGroups() + + username, groups, err := downstreamsession.ApplyIdentityTransformations(r.Context(), identityTransforms, upstreamUsername, upstreamGroups) + if err != nil { + oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, + ) + return nil + } + + customSessionData := downstreamsession.MakeDownstreamLDAPOrADCustomSessionData(ldapUpstream, idpType, authenticateResponse, username, upstreamUsername, upstreamGroups) openIDSession := downstreamsession.MakeDownstreamSession(subject, username, groups, authorizeRequester.GetGrantedScopes(), authorizeRequester.GetClient().GetID(), customSessionData, map[string]interface{}{}) oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, openIDSession, true) @@ -163,11 +217,12 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( generateCSRF func() (csrftoken.CSRFToken, error), generateNonce func() (nonce.Nonce, error), generatePKCE func() (pkce.Code, error), - ldapUpstream provider.UpstreamLDAPIdentityProviderI, + ldapUpstream *resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, idpType psession.ProviderType, downstreamIssuer string, upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, + idpNameQueryParamValue string, ) error { authRequestState, err := handleBrowserFlowAuthRequest( r, @@ -176,10 +231,11 @@ func handleAuthRequestForLDAPUpstreamBrowserFlow( generateCSRF, generateNonce, generatePKCE, - ldapUpstream.GetName(), + ldapUpstream.DisplayName, idpType, cookieCodec, upstreamStateEncoder, + idpNameQueryParamValue, ) if err != nil { return err @@ -196,18 +252,23 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, - oidcUpstream provider.UpstreamOIDCIdentityProviderI, + oidcUpstream upstreamprovider.UpstreamOIDCIdentityProviderI, + identityTransforms *idtransform.TransformationPipeline, + idpDisplayName string, + idpNameQueryParamValue string, ) error { authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, true) if !created { return nil } + maybeLogDeprecationWarningForMissingIDPParam(idpNameQueryParamValue, authorizeRequester) + if !requireStaticClientForUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) { return nil } - username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) + submittedUsername, submittedPassword, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) if !hadUsernamePasswordValues { return nil } @@ -220,7 +281,7 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } - token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), username, password) + token, err := oidcUpstream.PasswordCredentialsGrantAndValidateTokens(r.Context(), submittedUsername, submittedPassword) if err != nil { // Upstream password grant errors can be generic errors (e.g. a network failure) or can be oauth2.RetrieveError errors // which represent the http response from the upstream server. These could be a 5XX or some other unexpected error, @@ -234,7 +295,9 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } - subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) + subject, upstreamUsername, upstreamGroups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken( + oidcUpstream, token.IDToken.Claims, idpDisplayName, + ) if err != nil { // Return a user-friendly error for this case which is entirely within our control. oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, @@ -243,9 +306,17 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } + username, groups, err := downstreamsession.ApplyIdentityTransformations(r.Context(), identityTransforms, upstreamUsername, upstreamGroups) + if err != nil { + oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, + ) + return nil + } + additionalClaims := downstreamsession.MapAdditionalClaimsFromUpstreamIDToken(oidcUpstream, token.IDToken.Claims) - customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token, username) + customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(oidcUpstream, token, username, upstreamUsername, upstreamGroups) if err != nil { oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error()), true, @@ -268,10 +339,11 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( generateCSRF func() (csrftoken.CSRFToken, error), generateNonce func() (nonce.Nonce, error), generatePKCE func() (pkce.Code, error), - oidcUpstream provider.UpstreamOIDCIdentityProviderI, + oidcUpstream *resolvedprovider.FederationDomainResolvedOIDCIdentityProvider, downstreamIssuer string, upstreamStateEncoder oidc.Encoder, cookieCodec oidc.Codec, + idpNameQueryParamValue string, ) error { authRequestState, err := handleBrowserFlowAuthRequest( r, @@ -280,10 +352,11 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( generateCSRF, generateNonce, generatePKCE, - oidcUpstream.GetName(), + oidcUpstream.DisplayName, psession.ProviderTypeOIDC, cookieCodec, upstreamStateEncoder, + idpNameQueryParamValue, ) if err != nil { return err @@ -294,12 +367,12 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( } upstreamOAuthConfig := oauth2.Config{ - ClientID: oidcUpstream.GetClientID(), + ClientID: oidcUpstream.Provider.GetClientID(), Endpoint: oauth2.Endpoint{ - AuthURL: oidcUpstream.GetAuthorizationURL().String(), + AuthURL: oidcUpstream.Provider.GetAuthorizationURL().String(), }, RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer), - Scopes: oidcUpstream.GetScopes(), + Scopes: oidcUpstream.Provider.GetScopes(), } authCodeOptions := []oauth2.AuthCodeOption{ @@ -308,7 +381,7 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( authRequestState.pkce.Method(), } - for key, val := range oidcUpstream.GetAdditionalAuthcodeParams() { + for key, val := range oidcUpstream.Provider.GetAdditionalAuthcodeParams() { authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam(key, val)) } @@ -382,39 +455,31 @@ func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken { // chooseUpstreamIDP selects either an OIDC, an LDAP, or an AD IDP, or returns an error. // Note that AD and LDAP IDPs both return the same interface type, but different ProviderTypes values. -func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, psession.ProviderType, error) { - oidcUpstreams := idpLister.GetOIDCIdentityProviders() - ldapUpstreams := idpLister.GetLDAPIdentityProviders() - adUpstreams := idpLister.GetActiveDirectoryIdentityProviders() - switch { - case len(oidcUpstreams)+len(ldapUpstreams)+len(adUpstreams) == 0: - return nil, nil, "", httperr.New( - http.StatusUnprocessableEntity, - "No upstream providers are configured", - ) - case len(oidcUpstreams)+len(ldapUpstreams)+len(adUpstreams) > 1: - var upstreamIDPNames []string - for _, idp := range oidcUpstreams { - upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) - } - for _, idp := range ldapUpstreams { - upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) - } - for _, idp := range adUpstreams { - upstreamIDPNames = append(upstreamIDPNames, idp.GetName()) - } - plog.Warning("Too many upstream providers are configured (found: %s)", upstreamIDPNames) - return nil, nil, "", httperr.New( - http.StatusUnprocessableEntity, - "Too many upstream providers are configured (support for multiple upstreams is not yet implemented)", - ) - case len(oidcUpstreams) == 1: - return oidcUpstreams[0], nil, psession.ProviderTypeOIDC, nil - case len(adUpstreams) == 1: - return nil, adUpstreams[0], psession.ProviderTypeActiveDirectory, nil - default: - return nil, ldapUpstreams[0], psession.ProviderTypeLDAP, nil +func chooseUpstreamIDP(idpDisplayName string, idpLister federationdomainproviders.FederationDomainIdentityProvidersFinderI) (*resolvedprovider.FederationDomainResolvedOIDCIdentityProvider, *resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, error) { + // When a request is made to the authorization endpoint which does not specify the IDP name, then it might + // be an old dynamic client (OIDCClient). We need to make this work, but only in the backwards compatibility case + // where there is exactly one IDP defined in the namespace and no IDPs listed on the FederationDomain. + // This backwards compatibility mode is handled by FindDefaultIDP(). + if len(idpDisplayName) == 0 { + return idpLister.FindDefaultIDP() } + return idpLister.FindUpstreamIDPByDisplayName(idpDisplayName) +} + +func maybeLogDeprecationWarningForMissingIDPParam(idpNameQueryParamValue string, authorizeRequester fosite.AuthorizeRequester) { + if len(idpNameQueryParamValue) != 0 { + return + } + plog.Warning("Client attempted to perform an authorization flow (user login) without specifying the "+ + "query param to choose an identity provider. "+ + "This will not work when identity providers are configured explicitly on a FederationDomain. "+ + "Additionally, this behavior is deprecated and support for any authorization requests missing this query param "+ + "may be removed in a future release. "+ + "Please ask the author of this client to update the authorization request URL to include this query parameter. "+ + "The value of the parameter should be equal to the displayName of the identity provider as declared in the FederationDomain.", + "missingParameterName", oidcapi.AuthorizeUpstreamIDPNameParamName, + "clientID", authorizeRequester.GetClient().GetID(), + ) } type browserFlowAuthRequestState struct { @@ -438,16 +503,19 @@ func handleBrowserFlowAuthRequest( generateCSRF func() (csrftoken.CSRFToken, error), generateNonce func() (nonce.Nonce, error), generatePKCE func() (pkce.Code, error), - upstreamName string, + upstreamDisplayName string, idpType psession.ProviderType, cookieCodec oidc.Codec, upstreamStateEncoder oidc.Encoder, + idpNameQueryParamValue string, ) (*browserFlowAuthRequestState, error) { authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper, false) if !created { return nil, nil // already wrote the error response, don't return error } + maybeLogDeprecationWarningForMissingIDPParam(idpNameQueryParamValue, authorizeRequester) + now := time.Now() _, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{ Fosite: &openid.DefaultSession{ @@ -476,7 +544,7 @@ func handleBrowserFlowAuthRequest( encodedStateParamValue, err := upstreamStateParam( authorizeRequester, - upstreamName, + upstreamDisplayName, string(idpType), nonceValue, csrfValue, @@ -532,7 +600,7 @@ func generateValues( func upstreamStateParam( authorizeRequester fosite.AuthorizeRequester, - upstreamName string, + upstreamDisplayName string, upstreamType string, nonceValue nonce.Nonce, csrfValue csrftoken.CSRFToken, @@ -546,7 +614,7 @@ func upstreamStateParam( // The UpstreamName and UpstreamType struct fields can be used instead. // Remove those params here to avoid potential confusion about which should be used later. AuthParams: removeCustomIDPParams(authorizeRequester.GetRequestForm()).Encode(), - UpstreamName: upstreamName, + UpstreamName: upstreamDisplayName, UpstreamType: upstreamType, Nonce: nonceValue, CSRFToken: csrfValue, diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/federationdomain/endpoints/auth/auth_handler_test.go similarity index 79% rename from internal/oidc/auth/auth_handler_test.go rename to internal/federationdomain/endpoints/auth/auth_handler_test.go index d2c8e262..a7b5a246 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/federationdomain/endpoints/auth/auth_handler_test.go @@ -30,20 +30,21 @@ import ( supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/authenticators" + "go.pinniped.dev/internal/federationdomain/csrftoken" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/csrftoken" - "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" + "go.pinniped.dev/internal/testutil/transformtestutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/pkce" ) -func TestAuthorizationEndpoint(t *testing.T) { +func TestAuthorizationEndpoint(t *testing.T) { //nolint:gocyclo const ( oidcUpstreamName = "some-oidc-idp" oidcUpstreamResourceUID = "oidc-resource-uid" @@ -72,6 +73,7 @@ func TestAuthorizationEndpoint(t *testing.T) { downstreamPKCEChallengeMethod = "S256" happyState = "8b-state" upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev" + plainContentType = "text/plain; charset=utf-8" htmlContentType = "text/html; charset=utf-8" jsonContentType = "application/json; charset=utf-8" formContentType = "application/x-www-form-urlencoded" @@ -79,6 +81,9 @@ func TestAuthorizationEndpoint(t *testing.T) { pinnipedCLIClientID = "pinniped-cli" dynamicClientID = "client.oauth.pinniped.dev-test-name" dynamicClientUID = "fake-client-uid" + + transformationUsernamePrefix = "username_prefix:" + transformationGroupsPrefix = "groups_prefix:" ) require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -220,6 +225,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": happyState, } + fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. Reason: configured identity policy rejected this authentication: authentication was rejected by a configured policy.", + "state": happyState, + } + fositeLoginRequiredErrorQuery = map[string]string{ "error": "login_required", "error_description": "The Authorization Server requires End-User authentication.", @@ -232,18 +243,18 @@ func TestAuthorizationEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() - createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) { + createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *storage.KubeStorage) { // Configure fosite the same way that the production code would when using Kube storage. // Inject this into our test subject at the last second so we get a fresh storage for every test. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. - kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) + kubeOauthStore := storage.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore } - createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.NullStorage) { + createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *storage.NullStorage) { // Configure fosite the same way that the production code would, using NullStorage to turn off storage. // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. - nullOauthStore := oidc.NewNullStorage(secretsClient, oidcClientsClient, bcrypt.MinCost) + nullOauthStore := storage.NewNullStorage(secretsClient, oidcClientsClient, bcrypt.MinCost) return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore } @@ -323,27 +334,28 @@ func TestAuthorizationEndpoint(t *testing.T) { return nil, false, nil } - upstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: ldapUpstreamResourceUID, - URL: parsedUpstreamLDAPURL, - AuthenticateFunc: ldapAuthenticateFunc, + upstreamLDAPIdentityProviderBuilder := func() *oidctestutil.TestUpstreamLDAPIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder(). + WithName(ldapUpstreamName). + WithResourceUID(ldapUpstreamResourceUID). + WithURL(parsedUpstreamLDAPURL). + WithAuthenticateFunc(ldapAuthenticateFunc) } - upstreamActiveDirectoryIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: activeDirectoryUpstreamName, - ResourceUID: activeDirectoryUpstreamResourceUID, - URL: parsedUpstreamLDAPURL, - AuthenticateFunc: ldapAuthenticateFunc, + upstreamActiveDirectoryIdentityProviderBuilder := func() *oidctestutil.TestUpstreamLDAPIdentityProviderBuilder { + return oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder(). + WithName(activeDirectoryUpstreamName). + WithResourceUID(activeDirectoryUpstreamResourceUID). + WithURL(parsedUpstreamLDAPURL). + WithAuthenticateFunc(ldapAuthenticateFunc) } - erroringUpstreamLDAPIdentityProvider := oidctestutil.TestUpstreamLDAPIdentityProvider{ - Name: ldapUpstreamName, - ResourceUID: ldapUpstreamResourceUID, - AuthenticateFunc: func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) { + erroringUpstreamLDAPIdentityProvider := oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder(). + WithName(ldapUpstreamName). + WithResourceUID(ldapUpstreamResourceUID). + WithAuthenticateFunc(func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) { return nil, false, fmt.Errorf("some ldap upstream auth error") - }, - } + }).Build() happyCSRF := "test-csrf" happyPKCE := "test-pkce" @@ -408,26 +420,67 @@ func TestAuthorizationEndpoint(t *testing.T) { happyGetRequestPath := pathWithQuery("/some/path", happyGetRequestQueryMap) - modifiedHappyGetRequestQueryMap := func(queryOverrides map[string]string) map[string]string { - copyOfHappyGetRequestQueryMap := map[string]string{} - for k, v := range happyGetRequestQueryMap { - copyOfHappyGetRequestQueryMap[k] = v + modifiedQueryMap := func(modifyThisMap map[string]string, queryOverrides map[string]string) map[string]string { + copyModifyThisMap := map[string]string{} + for k, v := range modifyThisMap { + copyModifyThisMap[k] = v } for k, v := range queryOverrides { - _, hasKey := copyOfHappyGetRequestQueryMap[k] + _, hasKey := copyModifyThisMap[k] if v == "" && hasKey { - delete(copyOfHappyGetRequestQueryMap, k) + delete(copyModifyThisMap, k) } else { - copyOfHappyGetRequestQueryMap[k] = v + copyModifyThisMap[k] = v } } - return copyOfHappyGetRequestQueryMap + return copyModifyThisMap + } + + modifiedHappyGetRequestQueryMap := func(queryOverrides map[string]string) map[string]string { + return modifiedQueryMap(happyGetRequestQueryMap, queryOverrides) } modifiedHappyGetRequestPath := func(queryOverrides map[string]string) string { return pathWithQuery("/some/path", modifiedHappyGetRequestQueryMap(queryOverrides)) } + happyGetRequestPathForOIDCUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": oidcUpstreamName}) + happyGetRequestPathForOIDCPasswordGrantUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": oidcPasswordGrantUpstreamName}) + happyGetRequestPathForLDAPUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": ldapUpstreamName}) + happyGetRequestPathForADUpstream := modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": activeDirectoryUpstreamName}) + + modifiedHappyGetRequestPathForOIDCUpstream := func(queryOverrides map[string]string) string { + queryOverrides["pinniped_idp_name"] = oidcUpstreamName + return modifiedHappyGetRequestPath(queryOverrides) + } + modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream := func(queryOverrides map[string]string) string { + queryOverrides["pinniped_idp_name"] = oidcPasswordGrantUpstreamName + return modifiedHappyGetRequestPath(queryOverrides) + } + modifiedHappyGetRequestPathForLDAPUpstream := func(queryOverrides map[string]string) string { + queryOverrides["pinniped_idp_name"] = ldapUpstreamName + return modifiedHappyGetRequestPath(queryOverrides) + } + modifiedHappyGetRequestPathForADUpstream := func(queryOverrides map[string]string) string { + queryOverrides["pinniped_idp_name"] = activeDirectoryUpstreamName + return modifiedHappyGetRequestPath(queryOverrides) + } + + happyGetRequestQueryMapForOIDCUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": oidcUpstreamName}) + happyGetRequestQueryMapForOIDCPasswordGrantUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": oidcPasswordGrantUpstreamName}) + happyGetRequestQueryMapForLDAPUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": ldapUpstreamName}) + happyGetRequestQueryMapForADUpstream := modifiedQueryMap(happyGetRequestQueryMap, map[string]string{"pinniped_idp_name": activeDirectoryUpstreamName}) + + modifiedHappyGetRequestQueryMapForOIDCUpstream := func(queryOverrides map[string]string) map[string]string { + return modifiedQueryMap(happyGetRequestQueryMapForOIDCUpstream, queryOverrides) + } + modifiedHappyGetRequestQueryMapForLDAPUpstream := func(queryOverrides map[string]string) map[string]string { + return modifiedQueryMap(happyGetRequestQueryMapForLDAPUpstream, queryOverrides) + } + modifiedHappyGetRequestQueryMapForADUpstream := func(queryOverrides map[string]string) map[string]string { + return modifiedQueryMap(happyGetRequestQueryMapForADUpstream, queryOverrides) + } + expectedUpstreamStateParam := func(queryOverrides map[string]string, csrfValueOverride, upstreamName, upstreamType string) string { csrf := happyCSRF if csrfValueOverride != "" { @@ -466,12 +519,14 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyActiveDirectoryUpstreamCustomSession := &psession.CustomSessionData{ - Username: happyLDAPUsernameFromAuthenticator, - ProviderUID: activeDirectoryUpstreamResourceUID, - ProviderName: activeDirectoryUpstreamName, - ProviderType: psession.ProviderTypeActiveDirectory, - OIDC: nil, - LDAP: nil, + Username: happyLDAPUsernameFromAuthenticator, + UpstreamUsername: happyLDAPUsernameFromAuthenticator, + UpstreamGroups: happyLDAPGroups, + ProviderUID: activeDirectoryUpstreamResourceUID, + ProviderName: activeDirectoryUpstreamName, + ProviderType: psession.ProviderTypeActiveDirectory, + OIDC: nil, + LDAP: nil, ActiveDirectory: &psession.ActiveDirectorySessionData{ UserDN: happyLDAPUserDN, ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, @@ -479,11 +534,13 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyLDAPUpstreamCustomSession := &psession.CustomSessionData{ - Username: happyLDAPUsernameFromAuthenticator, - ProviderUID: ldapUpstreamResourceUID, - ProviderName: ldapUpstreamName, - ProviderType: psession.ProviderTypeLDAP, - OIDC: nil, + Username: happyLDAPUsernameFromAuthenticator, + UpstreamUsername: happyLDAPUsernameFromAuthenticator, + UpstreamGroups: happyLDAPGroups, + ProviderUID: ldapUpstreamResourceUID, + ProviderName: ldapUpstreamName, + ProviderType: psession.ProviderTypeLDAP, + OIDC: nil, LDAP: &psession.LDAPSessionData{ UserDN: happyLDAPUserDN, ExtraRefreshAttributes: map[string]string{happyLDAPExtraRefreshAttribute: happyLDAPExtraRefreshValue}, @@ -492,10 +549,12 @@ func TestAuthorizationEndpoint(t *testing.T) { } expectedHappyOIDCPasswordGrantCustomSession := &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: oidcPasswordGrantUpstreamResourceUID, - ProviderName: oidcPasswordGrantUpstreamName, - ProviderType: psession.ProviderTypeOIDC, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: oidcPasswordGrantUpstreamResourceUID, + ProviderName: oidcPasswordGrantUpstreamName, + ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: oidcPasswordGrantUpstreamRefreshToken, UpstreamSubject: oidcUpstreamSubject, @@ -503,19 +562,13 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } - expectedHappyOIDCPasswordGrantCustomSessionWithUsername := func(wantUsername string) *psession.CustomSessionData { - copyOfCustomSession := *expectedHappyOIDCPasswordGrantCustomSession - copyOfOIDC := *(expectedHappyOIDCPasswordGrantCustomSession.OIDC) - copyOfCustomSession.OIDC = ©OfOIDC - copyOfCustomSession.Username = wantUsername - return ©OfCustomSession - } - expectedHappyOIDCPasswordGrantCustomSessionWithAccessToken := &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: oidcPasswordGrantUpstreamResourceUID, - ProviderName: oidcPasswordGrantUpstreamName, - ProviderType: psession.ProviderTypeOIDC, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: oidcPasswordGrantUpstreamResourceUID, + ProviderName: oidcPasswordGrantUpstreamName, + ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamAccessToken: oidcUpstreamAccessToken, UpstreamSubject: oidcUpstreamSubject, @@ -523,6 +576,26 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } + withUsernameAndGroupsInCustomSession := func(expectedCustomSessionData *psession.CustomSessionData, wantDownstreamUsername string, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { + copyOfCustomSession := *expectedCustomSessionData + if expectedCustomSessionData.LDAP != nil { + copyOfLDAP := *(expectedCustomSessionData.LDAP) + copyOfCustomSession.LDAP = ©OfLDAP + } + if expectedCustomSessionData.OIDC != nil { + copyOfOIDC := *(expectedCustomSessionData.OIDC) + copyOfCustomSession.OIDC = ©OfOIDC + } + if expectedCustomSessionData.ActiveDirectory != nil { + copyOfActiveDirectory := *(expectedCustomSessionData.ActiveDirectory) + copyOfCustomSession.ActiveDirectory = ©OfActiveDirectory + } + copyOfCustomSession.Username = wantDownstreamUsername + copyOfCustomSession.UpstreamUsername = wantUpstreamUsername + copyOfCustomSession.UpstreamGroups = wantUpstreamGroups + return ©OfCustomSession + } + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, "some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI, @@ -538,6 +611,9 @@ func TestAuthorizationEndpoint(t *testing.T) { encodedIncomingCookieCSRFValue, err := happyCookieEncoder.Encode("csrf", incomingCookieCSRFValue) require.NoError(t, err) + prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) + rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) + type testCase struct { name string @@ -594,7 +670,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCUpstream, wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -612,7 +688,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -622,14 +698,70 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream browser flow happy path using GET without a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, + { + name: "OIDC upstream browser flow happy path using GET without a CSRF cookie using backwards compatibility mode to have a default IDP (display name does not need to be sent as query param)", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()). + WithDefaultIDPDisplayName(oidcUpstreamName), // specify which IDP is the backwards-compatibility mode IDP + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPath, // does not include IDP display name as query param + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, + { + name: "with multiple IDPs available, request chooses to use OIDC browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()). + WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPathForOIDCUpstream, // includes IDP display name of OIDC upstream + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, + { + name: "with multiple IDPs available, request chooses to use LDAP browser flow", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()). + WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: happyGetRequestPathForLDAPUpstream, // includes IDP display name of LDAP upstream wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -639,7 +771,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, @@ -647,7 +779,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -657,14 +789,14 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -674,7 +806,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, @@ -682,7 +814,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -694,14 +826,14 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "OIDC upstream password grant happy path using GET", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -712,6 +844,48 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyOIDCPasswordGrantCustomSession, }, + { + name: "OIDC upstream password grant happy path using GET with identity transformations which change username and groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), + method: http.MethodGet, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, + customUsernameHeader: ptr.To(oidcUpstreamUsername), + customPasswordHeader: ptr.To(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: transformationUsernamePrefix + oidcUpstreamUsername, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, oidcUpstreamGroupMembership), + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: withUsernameAndGroupsInCustomSession( + expectedHappyOIDCPasswordGrantCustomSession, + transformationUsernamePrefix+oidcUpstreamUsername, + oidcUpstreamUsername, + oidcUpstreamGroupMembership, + ), + }, + { + name: "OIDC upstream password grant with identity transformations which rejects auth", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), + method: http.MethodGet, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, + customUsernameHeader: ptr.To(oidcUpstreamUsername), + customPasswordHeader: ptr.To(oidcUpstreamPassword), + wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, + wantStatus: http.StatusFound, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery), + wantBodyString: "", + }, { name: "OIDC upstream password grant happy path using GET with additional claim mappings", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder(). @@ -724,14 +898,14 @@ func TestAuthorizationEndpoint(t *testing.T) { WithIDTokenClaim("upstreamOtherClaim", []interface{}{"hello", true}). Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -755,14 +929,14 @@ func TestAuthorizationEndpoint(t *testing.T) { WithIDTokenClaim("not-upstream", "value"). Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -776,15 +950,15 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP cli upstream happy path using GET", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + ldapUpstreamName + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -796,16 +970,56 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, }, { - name: "ActiveDirectory cli upstream happy path using GET", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + name: "LDAP cli upstream happy path using GET with identity transformations which change username and groups", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(upstreamLDAPIdentityProviderBuilder().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + ldapUpstreamName + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: transformationUsernamePrefix + happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, happyLDAPGroups), + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: withUsernameAndGroupsInCustomSession( + expectedHappyLDAPUpstreamCustomSession, + transformationUsernamePrefix+happyLDAPUsernameFromAuthenticator, + happyLDAPUsernameFromAuthenticator, + happyLDAPGroups, + ), + }, + { + name: "LDAP cli upstream with identity transformations which reject auth", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(upstreamLDAPIdentityProviderBuilder().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), + method: http.MethodGet, + path: happyGetRequestPathForLDAPUpstream, + customUsernameHeader: ptr.To(happyLDAPUsername), + customPasswordHeader: ptr.To(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithConfiguredPolicyRejectionHintErrorQuery), + wantBodyString: "", + }, + { + name: "ActiveDirectory cli upstream happy path using GET", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), + method: http.MethodGet, + path: happyGetRequestPathForADUpstream, + customUsernameHeader: ptr.To(happyLDAPUsername), + customPasswordHeader: ptr.To(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: htmlContentType, + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + activeDirectoryUpstreamName + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -825,7 +1039,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCUpstream, csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -835,14 +1049,14 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream browser flow happy path using GET with a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -852,14 +1066,14 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory upstream browser flow happy path using GET with a CSRF cookie", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, csrfCookie: "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue + " ", wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -878,7 +1092,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForOIDCUpstream), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -898,7 +1112,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), + body: encodeQuery(modifiedHappyGetRequestQueryMapForOIDCUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -908,7 +1122,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream browser flow happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -917,7 +1131,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForLDAPUpstream), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -927,7 +1141,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream browser flow happy path using POST with a dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, @@ -937,7 +1151,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), + body: encodeQuery(modifiedHappyGetRequestQueryMapForLDAPUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -947,7 +1161,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory upstream browser flow happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, generateNonce: happyNonceGenerator, @@ -956,7 +1170,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForADUpstream), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -966,7 +1180,7 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory upstream browser flow happy path using POST with a dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, generateCSRF: happyCSRFGenerator, generatePKCE: happyPKCEGenerator, @@ -976,7 +1190,7 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), + body: encodeQuery(modifiedHappyGetRequestQueryMapForADUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", @@ -990,14 +1204,14 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForOIDCPasswordGrantUpstream), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1010,17 +1224,17 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP cli upstream happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForLDAPUpstream), customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + ldapUpstreamName + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1033,17 +1247,17 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "Active Directory cli upstream happy path using POST", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(happyGetRequestQueryMap), + body: encodeQuery(happyGetRequestQueryMapForADUpstream), customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + activeDirectoryUpstreamName + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1063,7 +1277,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"prompt": "login"}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, @@ -1080,7 +1294,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"pinniped_idp_name": "currently-ignored", "pinniped_idp_type": "oidc"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"pinniped_idp_type": "oidc"}), contentType: formContentType, wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -1098,7 +1312,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "login"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"prompt": "login"}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantBodyStringWithLocationInHref: true, @@ -1115,7 +1329,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"prompt": "none"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"prompt": "none"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeLoginRequiredErrorQuery), @@ -1130,7 +1344,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCUpstream, csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped", wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -1149,7 +1363,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), wantStatus: http.StatusSeeOther, @@ -1171,7 +1385,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep, @@ -1191,7 +1405,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "OIDC upstream password grant happy path when downstream redirect uri matches what is configured for client except for the port number", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), customUsernameHeader: ptr.To(oidcUpstreamUsername), @@ -1200,7 +1414,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1213,9 +1427,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "LDAP upstream happy path when downstream redirect uri matches what is configured for client except for the port number", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client }), customUsernameHeader: ptr.To(happyLDAPUsername), @@ -1223,7 +1437,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: downstreamRedirectURIWithDifferentPort + `\?code=([^&]+)&scope=openid\+username\+groups&state=` + happyState, - wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&idpName=" + ldapUpstreamName + "&sub=" + happyLDAPUID, wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, wantDownstreamIDTokenGroups: happyLDAPGroups, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1243,7 +1457,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid offline_access"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"scope": "openid offline_access"}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, @@ -1257,14 +1471,14 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "OIDC password grant happy path when upstream IDP returned empty refresh token but it did return an access token and has a userinfo endpoint", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1279,14 +1493,14 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "OIDC password grant happy path when upstream IDP returned empty refresh token and an access token that has a short lifetime", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(1*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1296,11 +1510,13 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: oidcPasswordGrantUpstreamResourceUID, - ProviderName: oidcPasswordGrantUpstreamName, - ProviderType: psession.ProviderTypeOIDC, - Warnings: []string{"Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in."}, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: oidcPasswordGrantUpstreamResourceUID, + ProviderName: oidcPasswordGrantUpstreamName, + ProviderType: psession.ProviderTypeOIDC, + Warnings: []string{"Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in."}, OIDC: &psession.OIDCSessionData{ UpstreamAccessToken: oidcUpstreamAccessToken, UpstreamSubject: oidcUpstreamSubject, @@ -1312,14 +1528,14 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "OIDC password grant happy path when upstream IDP did not return a refresh token but it did return an access token and has a userinfo endpoint", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithUserInfoURL().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, wantStatus: http.StatusFound, wantContentType: htmlContentType, wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + oidcPasswordGrantUpstreamName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -1332,9 +1548,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error during upstream LDAP authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&erroringUpstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(erroringUpstreamLDAPIdentityProvider), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusBadGateway, @@ -1343,9 +1559,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "error during upstream Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(erroringUpstreamLDAPIdentityProvider), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusBadGateway, @@ -1361,7 +1577,7 @@ func TestAuthorizationEndpoint(t *testing.T) { Build(), ), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To("wrong-password"), wantPasswordGrantCall: &expectedPasswordGrant{ @@ -1377,9 +1593,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream password for LDAP authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To("wrong-password"), wantStatus: http.StatusFound, @@ -1389,9 +1605,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream password for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To("wrong-password"), wantStatus: http.StatusFound, @@ -1401,9 +1617,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream username for LDAP authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To("wrong-username"), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1413,9 +1629,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "wrong upstream username for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, customUsernameHeader: ptr.To("wrong-username"), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1427,7 +1643,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "missing upstream username but has password on request for OIDC password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: nil, // do not send header customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1437,9 +1653,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream username but has password on request for LDAP authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: nil, // do not send header customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1449,9 +1665,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream username on request for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, customUsernameHeader: nil, // do not send header customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1461,9 +1677,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream password on request for LDAP authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForLDAPUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, @@ -1473,9 +1689,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "missing upstream password on request for Active Directory authentication", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForADUpstream, customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, @@ -1487,7 +1703,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns no refresh token with an access token but has no userinfo endpoint", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithoutUserInfoURL().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1500,7 +1716,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns empty refresh token with an access token but has no userinfo endpoint", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithAccessToken(oidcUpstreamAccessToken, metav1.NewTime(time.Now().Add(9*time.Hour))).WithoutUserInfoURL().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1513,7 +1729,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns empty refresh token and empty access token", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1526,7 +1742,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns no refresh and no access token", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1539,7 +1755,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns no refresh token and empty access token", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithoutRefreshToken().WithEmptyAccessToken().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1552,7 +1768,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "password grant returns an error when upstream IDP returns empty refresh token and no access token", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().WithEmptyRefreshToken().WithoutAccessToken().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantPasswordGrantCall: happyUpstreamPasswordGrantMockExpectation, @@ -1565,7 +1781,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "missing upstream password on request for OIDC password grant authentication", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCPasswordGrantUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: nil, // do not send header wantStatus: http.StatusFound, @@ -1577,7 +1793,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "using the custom username header on request for OIDC password grant authentication when OIDCIdentityProvider does not allow password grants", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: happyGetRequestPath, + path: happyGetRequestPathForOIDCUpstream, customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1590,7 +1806,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1600,10 +1816,10 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "dynamic clients are not allowed to use LDAP CLI-flow authentication because we don't want them to handle user credentials", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1613,10 +1829,10 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "dynamic clients are not allowed to use Active Directory CLI-flow authentication because we don't want them to handle user credentials", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1633,7 +1849,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", }), wantStatus: http.StatusBadRequest, @@ -1650,7 +1866,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-dynamic-client", "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep, @@ -1663,7 +1879,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "downstream redirect uri does not match what is configured for client when using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", }), customUsernameHeader: ptr.To(oidcUpstreamUsername), @@ -1674,9 +1890,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream redirect uri does not match what is configured for client when using LDAP upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", }), customUsernameHeader: ptr.To(happyLDAPUsername), @@ -1687,9 +1903,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream redirect uri does not match what is configured for client when using active directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-pinniped-cli-client", }), customUsernameHeader: ptr.To(happyLDAPUsername), @@ -1707,7 +1923,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, @@ -1716,7 +1932,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "downstream client does not exist when using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{"client_id": "invalid-client"}), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusUnauthorized, @@ -1725,18 +1941,18 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "downstream client does not exist when using LDAP upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, }, { name: "downstream client does not exist when using active directory upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": "invalid-client"}), + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{"client_id": "invalid-client"}), wantStatus: http.StatusUnauthorized, wantContentType: jsonContentType, wantBodyJSON: fositeInvalidClientErrorBody, @@ -1750,7 +1966,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), @@ -1766,7 +1982,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep, @@ -1780,7 +1996,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "response type is unsupported when using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{"response_type": "unsupported"}), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1790,9 +2006,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using LDAP cli upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{"response_type": "unsupported"}), customUsernameHeader: ptr.To(happyLDAPUsername), customPasswordHeader: ptr.To(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1802,9 +2018,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using LDAP browser upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), @@ -1812,10 +2028,10 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using LDAP browser upstream with dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(upstreamLDAPIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForLDAPUpstream(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep, @@ -1827,9 +2043,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using active directory cli upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{"response_type": "unsupported"}), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1839,9 +2055,9 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using active directory browser upstream", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_type": "unsupported"}), + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{"response_type": "unsupported"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), @@ -1849,10 +2065,10 @@ func TestAuthorizationEndpoint(t *testing.T) { }, { name: "response type is unsupported when using active directory browser upstream with dynamic client", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(upstreamActiveDirectoryIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{ + path: modifiedHappyGetRequestPathForADUpstream(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep, @@ -1871,7 +2087,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"scope": "openid profile email tuna"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), @@ -1887,7 +2103,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": "openid tuna"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"client_id": dynamicClientID, "scope": "openid tuna"}), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), @@ -1897,7 +2113,7 @@ func TestAuthorizationEndpoint(t *testing.T) { name: "downstream scopes do not match what is configured for client using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"scope": "openid profile email tuna"}), + path: modifiedHappyGetRequestPathForOIDCPasswordGrantUpstream(map[string]string{"scope": "openid profile email tuna"}), customUsernameHeader: ptr.To(oidcUpstreamUsername), customPasswordHeader: ptr.To(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1914,7 +2130,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_mode": "form_post", "scope": "openid profile email tuna"}), + path: modifiedHappyGetRequestPathForOIDCUpstream(map[string]string{"response_mode": "form_post", "scope": "openid profile email tuna"}), wantStatus: http.StatusOK, wantContentType: htmlContentType, wantBodyRegex: ` 0 { require.True(t, len(idps.GetOIDCIdentityProviders()) > 0, "wantDownstreamAdditionalClaims requires at least one OIDC IDP") } @@ -3268,7 +3520,7 @@ func TestAuthorizationEndpoint(t *testing.T) { oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace") oauthHelperWithRealStorage, kubeOauthStore := createOauthHelperWithRealStorage(secretsClient, oidcClientsClient) oauthHelperWithNullStorage, _ := createOauthHelperWithNullStorage(secretsClient, oidcClientsClient) - idpLister := test.idps.Build() + idpLister := test.idps.BuildFederationDomainIdentityProvidersListerFinder() subject := NewHandler( downstreamIssuer, idpLister, @@ -3287,7 +3539,12 @@ func TestAuthorizationEndpoint(t *testing.T) { WithScopes([]string{"some-other-new-scope1", "some-other-new-scope2"}). WithAdditionalAuthcodeParams(map[string]string{"prompt": "consent", "abc": "123"}). Build() - idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{provider.UpstreamOIDCIdentityProviderI(newProviderSettings)}) + idpLister.SetOIDCIdentityProviders([]*oidctestutil.TestUpstreamOIDCIdentityProvider{newProviderSettings}) + + test.path = modifiedHappyGetRequestPath(map[string]string{ + // update the IDP name in the request to match the name of the new IDP + "pinniped_idp_name": "some-other-new-idp-name", + }) // Update the expectations of the test case to match the new upstream IDP settings. test.wantLocationHeader = urlWithQuery(upstreamAuthURL.String(), diff --git a/internal/oidc/callback/callback_handler.go b/internal/federationdomain/endpoints/callback/callback_handler.go similarity index 79% rename from internal/oidc/callback/callback_handler.go rename to internal/federationdomain/endpoints/callback/callback_handler.go index 05337b68..cdc0cc96 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/federationdomain/endpoints/callback/callback_handler.go @@ -10,17 +10,17 @@ import ( "github.com/ory/fosite" + "go.pinniped.dev/internal/federationdomain/downstreamsession" + "go.pinniped.dev/internal/federationdomain/federationdomainproviders" + "go.pinniped.dev/internal/federationdomain/formposthtml" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/downstreamsession" - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" ) func NewHandler( - upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister, + upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersFinderI, oauthHelper fosite.OAuth2Provider, stateDecoder, cookieDecoder oidc.Decoder, redirectURI string, @@ -31,11 +31,12 @@ func NewHandler( return err } - upstreamIDPConfig := findUpstreamIDPConfig(state.UpstreamName, upstreamIDPs) - if upstreamIDPConfig == nil { + resolvedOIDCIdentityProvider, _, err := upstreamIDPs.FindUpstreamIDPByDisplayName(state.UpstreamName) + if err != nil || resolvedOIDCIdentityProvider == nil { plog.Warning("upstream provider not found") return httperr.New(http.StatusUnprocessableEntity, "upstream provider not found") } + upstreamIDPConfig := resolvedOIDCIdentityProvider.Provider downstreamAuthParams, err := url.ParseQuery(state.AuthParams) if err != nil { @@ -69,14 +70,25 @@ func NewHandler( return httperr.New(http.StatusBadGateway, "error exchanging and validating upstream tokens") } - subject, username, groups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) + subject, upstreamUsername, upstreamGroups, err := downstreamsession.GetDownstreamIdentityFromUpstreamIDToken( + upstreamIDPConfig, token.IDToken.Claims, resolvedOIDCIdentityProvider.DisplayName, + ) + if err != nil { + return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) + } + + username, groups, err := downstreamsession.ApplyIdentityTransformations( + r.Context(), resolvedOIDCIdentityProvider.Transforms, upstreamUsername, upstreamGroups, + ) if err != nil { return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } additionalClaims := downstreamsession.MapAdditionalClaimsFromUpstreamIDToken(upstreamIDPConfig, token.IDToken.Claims) - customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData(upstreamIDPConfig, token, username) + customSessionData, err := downstreamsession.MakeDownstreamOIDCCustomSessionData( + upstreamIDPConfig, token, username, upstreamUsername, upstreamGroups, + ) if err != nil { return httperr.Wrap(http.StatusUnprocessableEntity, err.Error(), err) } @@ -120,12 +132,3 @@ func validateRequest(r *http.Request, stateDecoder, cookieDecoder oidc.Decoder) return decodedState, nil } - -func findUpstreamIDPConfig(upstreamName string, upstreamIDPs oidc.UpstreamOIDCIdentityProvidersLister) provider.UpstreamOIDCIdentityProviderI { - for _, p := range upstreamIDPs.GetOIDCIdentityProviders() { - if p.GetName() == upstreamName { - return p - } - } - return nil -} diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/federationdomain/endpoints/callback/callback_handler_test.go similarity index 89% rename from internal/oidc/callback/callback_handler_test.go rename to internal/federationdomain/endpoints/callback/callback_handler_test.go index a9f185c8..c7ff91e8 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/federationdomain/endpoints/callback/callback_handler_test.go @@ -21,12 +21,14 @@ import ( configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" + "go.pinniped.dev/internal/testutil/transformtestutil" "go.pinniped.dev/pkg/oidcclient/nonce" oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce" ) @@ -64,6 +66,9 @@ const ( downstreamPKCEChallengeMethod = "S256" htmlContentType = "text/html; charset=utf-8" + + transformationUsernamePrefix = "username_prefix:" + transformationGroupsPrefix = "groups_prefix:" ) var ( @@ -89,28 +94,34 @@ var ( happyDownstreamRequestParamsForDynamicClient = happyDownstreamRequestParamsQueryForDynamicClient.Encode() happyDownstreamCustomSessionData = &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, - ProviderType: psession.ProviderTypeOIDC, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: happyUpstreamIDPResourceUID, + ProviderName: happyUpstreamIDPName, + ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: oidcUpstreamRefreshToken, UpstreamIssuer: oidcUpstreamIssuer, UpstreamSubject: oidcUpstreamSubject, }, } - happyDownstreamCustomSessionDataWithUsername = func(wantUsername string) *psession.CustomSessionData { + happyDownstreamCustomSessionDataWithUsernameAndGroups = func(wantDownstreamUsername, wantUpstreamUsername string, wantUpstreamGroups []string) *psession.CustomSessionData { copyOfCustomSession := *happyDownstreamCustomSessionData copyOfOIDC := *(happyDownstreamCustomSessionData.OIDC) copyOfCustomSession.OIDC = ©OfOIDC - copyOfCustomSession.Username = wantUsername + copyOfCustomSession.Username = wantDownstreamUsername + copyOfCustomSession.UpstreamUsername = wantUpstreamUsername + copyOfCustomSession.UpstreamGroups = wantUpstreamGroups return ©OfCustomSession } happyDownstreamAccessTokenCustomSessionData = &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, - ProviderType: psession.ProviderTypeOIDC, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: happyUpstreamIDPResourceUID, + ProviderName: happyUpstreamIDPName, + ProviderType: psession.ProviderTypeOIDC, OIDC: &psession.OIDCSessionData{ UpstreamAccessToken: oidcUpstreamAccessToken, UpstreamIssuer: oidcUpstreamIssuer, @@ -122,11 +133,11 @@ var ( func TestCallbackEndpoint(t *testing.T) { require.Len(t, happyDownstreamState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") - otherUpstreamOIDCIdentityProvider := oidctestutil.TestUpstreamOIDCIdentityProvider{ - Name: "other-upstream-idp-name", - ClientID: "other-some-client-id", - Scopes: []string{"other-scope1", "other-scope2"}, - } + otherUpstreamOIDCIdentityProvider := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). + WithName("other-upstream-idp-name"). + WithClientID("other-some-client-id"). + WithScopes([]string{"other-scope1", "other-scope2"}). + Build() var stateEncoderHashKey = []byte("fake-hash-secret") var stateEncoderBlockKey = []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES @@ -165,6 +176,9 @@ func TestCallbackEndpoint(t *testing.T) { require.NoError(t, kubeClient.Tracker().Add(secret)) } + prefixUsernameAndGroupsPipeline := transformtestutil.NewPrefixingPipeline(t, transformationUsernamePrefix, transformationGroupsPrefix) + rejectAuthPipeline := transformtestutil.NewRejectAllAuthPipeline(t) + tests := []struct { name string @@ -209,7 +223,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -248,7 +262,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -276,7 +290,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -301,7 +315,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -325,7 +339,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -359,7 +373,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusOK, wantContentType: "text/html;charset=UTF-8", wantBodyFormResponseRegexp: `(.+)`, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamRequestedScopes: []string{"openid"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -384,7 +398,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -394,11 +408,13 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: &psession.CustomSessionData{ - Username: oidcUpstreamUsername, - ProviderUID: happyUpstreamIDPResourceUID, - ProviderName: happyUpstreamIDPName, - ProviderType: psession.ProviderTypeOIDC, - Warnings: []string{"Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in."}, + Username: oidcUpstreamUsername, + UpstreamUsername: oidcUpstreamUsername, + UpstreamGroups: oidcUpstreamGroupMembership, + ProviderUID: happyUpstreamIDPResourceUID, + ProviderName: happyUpstreamIDPName, + ProviderType: psession.ProviderTypeOIDC, + Warnings: []string{"Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in."}, OIDC: &psession.OIDCSessionData{ UpstreamAccessToken: oidcUpstreamAccessToken, UpstreamIssuer: oidcUpstreamIssuer, @@ -421,7 +437,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenGroups: []string{}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -430,7 +446,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped), + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + oidcUpstreamIssuer+"?sub="+oidcUpstreamSubjectQueryEscaped, + oidcUpstreamIssuer+"?sub="+oidcUpstreamSubjectQueryEscaped, + nil, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -447,7 +467,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -456,7 +476,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"), + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + "joe@whitehouse.gov", + "joe@whitehouse.gov", + oidcUpstreamGroupMembership, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -475,7 +499,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe@whitehouse.gov", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -484,7 +508,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe@whitehouse.gov"), + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + "joe@whitehouse.gov", + "joe@whitehouse.gov", + oidcUpstreamGroupMembership, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -504,7 +532,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, // succeed despite `email_verified=false` because we're not using the email claim for anything wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "joe", wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -513,7 +541,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername("joe"), + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + "joe", + "joe", + oidcUpstreamGroupMembership, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -635,7 +667,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamSubject, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -644,7 +676,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsername(oidcUpstreamSubject), + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + oidcUpstreamSubject, + oidcUpstreamSubject, + oidcUpstreamGroupMembership, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -661,7 +697,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"notAnArrayGroup1 notAnArrayGroup2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -670,7 +706,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + oidcUpstreamUsername, + oidcUpstreamUsername, + []string{"notAnArrayGroup1 notAnArrayGroup2"}, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -687,7 +727,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: []string{"group1", "group2"}, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, @@ -696,7 +736,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + oidcUpstreamUsername, + oidcUpstreamUsername, + []string{"group1", "group2"}, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -717,7 +761,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "", // username scope was not requested wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, @@ -747,7 +791,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: nil, // groups scope was not requested wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, @@ -790,7 +834,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+groups&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: "", // username scope was not requested wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamRequestedScopes: []string{"openid", "groups", "offline_access"}, @@ -833,7 +877,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username&state=` + happyDownstreamState, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: nil, // groups scope was not requested wantDownstreamRequestedScopes: []string{"openid", "username", "offline_access"}, @@ -848,6 +892,35 @@ func TestCallbackEndpoint(t *testing.T) { args: happyExchangeAndValidateTokensArgs, }, }, + { + name: "using identity transformations which modify the username and group names", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(happyUpstream().WithTransformsForFederationDomain(prefixUsernameAndGroupsPipeline).Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: transformationUsernamePrefix + oidcUpstreamUsername, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach(transformationGroupsPrefix, oidcUpstreamGroupMembership), + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + transformationUsernamePrefix+oidcUpstreamUsername, + oidcUpstreamUsername, + oidcUpstreamGroupMembership, + ), + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, // Pre-upstream-exchange verification { @@ -1085,7 +1158,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"profile", "email", "username", "groups"}, wantDownstreamGrantedScopes: []string{"username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -1115,7 +1188,7 @@ func TestCallbackEndpoint(t *testing.T) { wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"profile", "email"}, // username and groups scopes were not requested but are granted anyway for the pinniped-cli client for backwards compatibility wantDownstreamGrantedScopes: []string{"username", "groups"}, @@ -1144,7 +1217,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+username\+groups&state=` + happyDownstreamState, wantDownstreamIDTokenUsername: oidcUpstreamUsername, - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamRequestedScopes: []string{"openid", "offline_access", "username", "groups"}, wantDownstreamGrantedScopes: []string{"openid", "offline_access", "username", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, @@ -1159,8 +1232,8 @@ func TestCallbackEndpoint(t *testing.T) { }, }, { - name: "the OIDCIdentityProvider CRD has been deleted", - idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(&otherUpstreamOIDCIdentityProvider), + name: "the OIDCIdentityProvider resource has been deleted", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(otherUpstreamOIDCIdentityProvider), method: http.MethodGet, path: newRequestPath().WithState(happyState).String(), csrfCookie: happyCSRFCookie, @@ -1242,7 +1315,7 @@ func TestCallbackEndpoint(t *testing.T) { wantStatus: http.StatusSeeOther, wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, wantBody: "", - wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?idpName=" + happyUpstreamIDPName + "&sub=" + oidcUpstreamSubjectQueryEscaped, wantDownstreamIDTokenUsername: oidcUpstreamUsername, wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, @@ -1251,7 +1324,11 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, - wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionDataWithUsernameAndGroups( + oidcUpstreamUsername, + oidcUpstreamUsername, + nil, + ), wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ performedByUpstreamName: happyUpstreamIDPName, args: happyExchangeAndValidateTokensArgs, @@ -1433,7 +1510,23 @@ func TestCallbackEndpoint(t *testing.T) { args: happyExchangeAndValidateTokensArgs, }, }, + { + name: "using identity transformations which reject the authentication", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithOIDC(happyUpstream().WithTransformsForFederationDomain(rejectAuthPipeline).Build()), + method: http.MethodGet, + path: newRequestPath().WithState(happyState).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusUnprocessableEntity, + wantContentType: htmlContentType, + wantBody: "Unprocessable Entity: configured identity policy rejected this authentication: authentication was rejected by a configured policy\n", + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, } + for _, test := range tests { test := test @@ -1451,13 +1544,13 @@ func TestCallbackEndpoint(t *testing.T) { // Inject this into our test subject at the last second so we get a fresh storage for every test. timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. - oauthStore := oidc.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) + oauthStore := storage.NewKubeStorage(secrets, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) hmacSecretFunc := func() []byte { return []byte("some secret - must have at least 32 bytes") } require.GreaterOrEqual(t, len(hmacSecretFunc()), 32, "fosite requires that hmac secrets have at least 32 bytes") jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() oauthHelper := oidc.FositeOauth2Helper(oauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) - subject := NewHandler(test.idps.Build(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) + subject := NewHandler(test.idps.BuildFederationDomainIdentityProvidersListerFinder(), oauthHelper, happyStateCodec, happyCookieCodec, happyUpstreamRedirectURI) reqContext := context.WithValue(context.Background(), struct{ name string }{name: "test"}, "request-context") req := httptest.NewRequest(test.method, test.path, nil).WithContext(reqContext) if test.csrfCookie != "" { diff --git a/internal/oidc/discovery/discovery_handler.go b/internal/federationdomain/endpoints/discovery/discovery_handler.go similarity index 98% rename from internal/oidc/discovery/discovery_handler.go rename to internal/federationdomain/endpoints/discovery/discovery_handler.go index 60609c3d..b7389a84 100644 --- a/internal/oidc/discovery/discovery_handler.go +++ b/internal/federationdomain/endpoints/discovery/discovery_handler.go @@ -11,7 +11,7 @@ import ( "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" - "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/federationdomain/oidc" ) // Metadata holds all fields (that we care about) from the OpenID Provider Metadata section in the diff --git a/internal/oidc/discovery/discovery_handler_test.go b/internal/federationdomain/endpoints/discovery/discovery_handler_test.go similarity index 98% rename from internal/oidc/discovery/discovery_handler_test.go rename to internal/federationdomain/endpoints/discovery/discovery_handler_test.go index 6896b564..cd205974 100644 --- a/internal/oidc/discovery/discovery_handler_test.go +++ b/internal/federationdomain/endpoints/discovery/discovery_handler_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidc" ) func TestDiscovery(t *testing.T) { diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler.go b/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go similarity index 69% rename from internal/oidc/idpdiscovery/idp_discovery_handler.go rename to internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go index 66a974c9..5efb9a57 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler.go +++ b/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package idpdiscovery provides a handler for the upstream IDP discovery endpoint. @@ -11,11 +11,11 @@ import ( "sort" "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" - "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/federationdomain/federationdomainproviders" ) // NewHandler returns an http.Handler that serves the upstream IDP discovery endpoint. -func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler { +func NewHandler(upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersListerI) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, `Method not allowed (try GET)`, http.StatusMethodNotAllowed) @@ -36,31 +36,31 @@ func NewHandler(upstreamIDPs oidc.UpstreamIdentityProvidersLister) http.Handler }) } -func responseAsJSON(upstreamIDPs oidc.UpstreamIdentityProvidersLister) ([]byte, error) { +func responseAsJSON(upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersListerI) ([]byte, error) { r := v1alpha1.IDPDiscoveryResponse{PinnipedIDPs: []v1alpha1.PinnipedIDP{}} // The cache of IDPs could change at any time, so always recalculate the list. - for _, provider := range upstreamIDPs.GetLDAPIdentityProviders() { + for _, federationDomainIdentityProvider := range upstreamIDPs.GetLDAPIdentityProviders() { r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ - Name: provider.GetName(), + Name: federationDomainIdentityProvider.DisplayName, Type: v1alpha1.IDPTypeLDAP, Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword, v1alpha1.IDPFlowBrowserAuthcode}, }) } - for _, provider := range upstreamIDPs.GetActiveDirectoryIdentityProviders() { + for _, federationDomainIdentityProvider := range upstreamIDPs.GetActiveDirectoryIdentityProviders() { r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ - Name: provider.GetName(), + Name: federationDomainIdentityProvider.DisplayName, Type: v1alpha1.IDPTypeActiveDirectory, Flows: []v1alpha1.IDPFlow{v1alpha1.IDPFlowCLIPassword, v1alpha1.IDPFlowBrowserAuthcode}, }) } - for _, provider := range upstreamIDPs.GetOIDCIdentityProviders() { + for _, federationDomainIdentityProvider := range upstreamIDPs.GetOIDCIdentityProviders() { flows := []v1alpha1.IDPFlow{v1alpha1.IDPFlowBrowserAuthcode} - if provider.AllowsPasswordGrant() { + if federationDomainIdentityProvider.Provider.AllowsPasswordGrant() { flows = append(flows, v1alpha1.IDPFlowCLIPassword) } r.PinnipedIDPs = append(r.PinnipedIDPs, v1alpha1.PinnipedIDP{ - Name: provider.GetName(), + Name: federationDomainIdentityProvider.DisplayName, Type: v1alpha1.IDPTypeOIDC, Flows: flows, }) diff --git a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go b/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler_test.go similarity index 66% rename from internal/oidc/idpdiscovery/idp_discovery_handler_test.go rename to internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler_test.go index b33ab2d8..5257c254 100644 --- a/internal/oidc/idpdiscovery/idp_discovery_handler_test.go +++ b/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package idpdiscovery @@ -10,9 +10,8 @@ import ( "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/here" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil/oidctestutil" ) @@ -71,15 +70,15 @@ func TestIDPDiscovery(t *testing.T) { test := test t.Run(test.name, func(t *testing.T) { idpLister := oidctestutil.NewUpstreamIDPListerBuilder(). - WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "z-some-oidc-idp", AllowPasswordGrant: true}). - WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "x-some-idp"}). - WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "a-some-ldap-idp"}). - WithOIDC(&oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "a-some-oidc-idp"}). - WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "z-some-ldap-idp"}). - WithLDAP(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "x-some-idp"}). - WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "z-some-ad-idp"}). - WithActiveDirectory(&oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "y-some-ad-idp"}). - Build() + WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("z-some-oidc-idp").WithAllowPasswordGrant(true).Build()). + WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-idp").Build()). + WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("a-some-ldap-idp").Build()). + WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("a-some-oidc-idp").Build()). + WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ldap-idp").Build()). + WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("x-some-idp").Build()). + WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-some-ad-idp").Build()). + WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("y-some-ad-idp").Build()). + BuildFederationDomainIdentityProvidersListerFinder() handler := NewHandler(idpLister) req := httptest.NewRequest(test.method, test.path, nil) @@ -99,18 +98,17 @@ func TestIDPDiscovery(t *testing.T) { } // Change the list of IDPs in the cache. - idpLister.SetLDAPIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ - &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-1"}, - &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ldap-idp-2"}, + idpLister.SetLDAPIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{ + oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ldap-idp-1").Build(), + oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ldap-idp-2").Build(), }) - idpLister.SetOIDCIdentityProviders([]provider.UpstreamOIDCIdentityProviderI{ - &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-1", AllowPasswordGrant: true}, - &oidctestutil.TestUpstreamOIDCIdentityProvider{Name: "some-other-oidc-idp-2"}, + idpLister.SetOIDCIdentityProviders([]*oidctestutil.TestUpstreamOIDCIdentityProvider{ + oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("some-other-oidc-idp-1").WithAllowPasswordGrant(true).Build(), + oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("some-other-oidc-idp-2").Build(), }) - - idpLister.SetActiveDirectoryIdentityProviders([]provider.UpstreamLDAPIdentityProviderI{ - &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ad-idp-2"}, - &oidctestutil.TestUpstreamLDAPIdentityProvider{Name: "some-other-ad-idp-1"}, + idpLister.SetActiveDirectoryIdentityProviders([]*oidctestutil.TestUpstreamLDAPIdentityProvider{ + oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ad-idp-2").Build(), + oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("some-other-ad-idp-1").Build(), }) // Make the same request to the same handler instance again, and expect different results. diff --git a/internal/oidc/jwks/dynamic_jwks_provider.go b/internal/federationdomain/endpoints/jwks/dynamic_jwks_provider.go similarity index 94% rename from internal/oidc/jwks/dynamic_jwks_provider.go rename to internal/federationdomain/endpoints/jwks/dynamic_jwks_provider.go index fa156e3c..cb8f8e41 100644 --- a/internal/oidc/jwks/dynamic_jwks_provider.go +++ b/internal/federationdomain/endpoints/jwks/dynamic_jwks_provider.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package jwks diff --git a/internal/oidc/jwks/jwks_handler.go b/internal/federationdomain/endpoints/jwks/jwks_handler.go similarity index 92% rename from internal/oidc/jwks/jwks_handler.go rename to internal/federationdomain/endpoints/jwks/jwks_handler.go index 2c975e95..1d9eb7df 100644 --- a/internal/oidc/jwks/jwks_handler.go +++ b/internal/federationdomain/endpoints/jwks/jwks_handler.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package discovery provides a handler for the OIDC discovery endpoint. diff --git a/internal/oidc/jwks/jwks_handler_test.go b/internal/federationdomain/endpoints/jwks/jwks_handler_test.go similarity index 97% rename from internal/oidc/jwks/jwks_handler_test.go rename to internal/federationdomain/endpoints/jwks/jwks_handler_test.go index 37f53e8c..69d624ce 100644 --- a/internal/oidc/jwks/jwks_handler_test.go +++ b/internal/federationdomain/endpoints/jwks/jwks_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package jwks diff --git a/internal/oidc/login/get_login_handler.go b/internal/federationdomain/endpoints/login/get_login_handler.go similarity index 84% rename from internal/oidc/login/get_login_handler.go rename to internal/federationdomain/endpoints/login/get_login_handler.go index d6da85a6..567e6a9e 100644 --- a/internal/oidc/login/get_login_handler.go +++ b/internal/federationdomain/endpoints/login/get_login_handler.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -6,8 +6,8 @@ package login import ( "net/http" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/login/loginhtml" + "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" + "go.pinniped.dev/internal/federationdomain/oidc" ) const ( diff --git a/internal/oidc/login/get_login_handler_test.go b/internal/federationdomain/endpoints/login/get_login_handler_test.go similarity index 92% rename from internal/oidc/login/get_login_handler_test.go rename to internal/federationdomain/endpoints/login/get_login_handler_test.go index bb85b8f2..74405d49 100644 --- a/internal/oidc/login/get_login_handler_test.go +++ b/internal/federationdomain/endpoints/login/get_login_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -10,8 +10,9 @@ import ( "github.com/stretchr/testify/require" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/login/loginhtml" + "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" + "go.pinniped.dev/internal/federationdomain/idplister" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/testutil" ) @@ -28,7 +29,7 @@ func TestGetLogin(t *testing.T) { decodedState *oidc.UpstreamStateParamData encodedState string errParam string - idps oidc.UpstreamIdentityProvidersLister + idps idplister.UpstreamIdentityProvidersLister wantStatus int wantContentType string wantBody string diff --git a/internal/oidc/login/login_handler.go b/internal/federationdomain/endpoints/login/login_handler.go similarity index 94% rename from internal/oidc/login/login_handler.go rename to internal/federationdomain/endpoints/login/login_handler.go index 1b358f2b..f7892c70 100644 --- a/internal/oidc/login/login_handler.go +++ b/internal/federationdomain/endpoints/login/login_handler.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -8,11 +8,11 @@ import ( "net/url" idpdiscoveryv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" + "go.pinniped.dev/internal/federationdomain/endpoints/login/loginhtml" + "go.pinniped.dev/internal/federationdomain/formposthtml" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/login/loginhtml" - "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" ) diff --git a/internal/oidc/login/login_handler_test.go b/internal/federationdomain/endpoints/login/login_handler_test.go similarity index 99% rename from internal/oidc/login/login_handler_test.go rename to internal/federationdomain/endpoints/login/login_handler_test.go index 11380950..854484b3 100644 --- a/internal/oidc/login/login_handler_test.go +++ b/internal/federationdomain/endpoints/login/login_handler_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package login @@ -13,8 +13,8 @@ import ( "github.com/gorilla/securecookie" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/federationdomain/oidc" "go.pinniped.dev/internal/httputil/httperr" - "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" ) diff --git a/internal/oidc/login/loginhtml/login_form.css b/internal/federationdomain/endpoints/login/loginhtml/login_form.css similarity index 96% rename from internal/oidc/login/loginhtml/login_form.css rename to internal/federationdomain/endpoints/login/loginhtml/login_form.css index 5eba47e0..19680cc4 100644 --- a/internal/oidc/login/loginhtml/login_form.css +++ b/internal/federationdomain/endpoints/login/loginhtml/login_form.css @@ -1,4 +1,4 @@ -/* Copyright 2022 the Pinniped contributors. All Rights Reserved. */ +/* Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. */ /* SPDX-License-Identifier: Apache-2.0 */ html { diff --git a/internal/oidc/login/loginhtml/login_form.gohtml b/internal/federationdomain/endpoints/login/loginhtml/login_form.gohtml similarity index 99% rename from internal/oidc/login/loginhtml/login_form.gohtml rename to internal/federationdomain/endpoints/login/loginhtml/login_form.gohtml index c1ab8ba3..7ae90e8c 100644 --- a/internal/oidc/login/loginhtml/login_form.gohtml +++ b/internal/federationdomain/endpoints/login/loginhtml/login_form.gohtml @@ -1,5 +1,5 @@ diff --git a/internal/oidc/provider/formposthtml/form_post.js b/internal/federationdomain/formposthtml/form_post.js similarity index 98% rename from internal/oidc/provider/formposthtml/form_post.js rename to internal/federationdomain/formposthtml/form_post.js index cb73c8cd..dcf86275 100644 --- a/internal/oidc/provider/formposthtml/form_post.js +++ b/internal/federationdomain/formposthtml/form_post.js @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 window.onload = () => { diff --git a/internal/oidc/provider/formposthtml/formposthtml.go b/internal/federationdomain/formposthtml/formposthtml.go similarity index 94% rename from internal/oidc/provider/formposthtml/formposthtml.go rename to internal/federationdomain/formposthtml/formposthtml.go index 4fd709c1..cdf2b85b 100644 --- a/internal/oidc/provider/formposthtml/formposthtml.go +++ b/internal/federationdomain/formposthtml/formposthtml.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package formposthtml defines HTML templates used by the Supervisor. @@ -11,7 +11,7 @@ import ( "github.com/tdewolff/minify/v2/minify" - "go.pinniped.dev/internal/oidc/provider/csp" + "go.pinniped.dev/internal/federationdomain/csp" ) //nolint:gochecknoglobals // This package uses globals to ensure that all parsing and minifying happens at init. diff --git a/internal/oidc/provider/formposthtml/formposthtml_test.go b/internal/federationdomain/formposthtml/formposthtml_test.go similarity index 99% rename from internal/oidc/provider/formposthtml/formposthtml_test.go rename to internal/federationdomain/formposthtml/formposthtml_test.go index e7d82b75..3ab3a49a 100644 --- a/internal/oidc/provider/formposthtml/formposthtml_test.go +++ b/internal/federationdomain/formposthtml/formposthtml_test.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package formposthtml diff --git a/internal/federationdomain/idplister/upstream_idp_lister.go b/internal/federationdomain/idplister/upstream_idp_lister.go new file mode 100644 index 00000000..38b5e27e --- /dev/null +++ b/internal/federationdomain/idplister/upstream_idp_lister.go @@ -0,0 +1,26 @@ +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package idplister + +import ( + "go.pinniped.dev/internal/federationdomain/upstreamprovider" +) + +type UpstreamOIDCIdentityProvidersLister interface { + GetOIDCIdentityProviders() []upstreamprovider.UpstreamOIDCIdentityProviderI +} + +type UpstreamLDAPIdentityProvidersLister interface { + GetLDAPIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI +} + +type UpstreamActiveDirectoryIdentityProviderLister interface { + GetActiveDirectoryIdentityProviders() []upstreamprovider.UpstreamLDAPIdentityProviderI +} + +type UpstreamIdentityProvidersLister interface { + UpstreamOIDCIdentityProvidersLister + UpstreamLDAPIdentityProvidersLister + UpstreamActiveDirectoryIdentityProviderLister +} diff --git a/internal/oidc/oidc.go b/internal/federationdomain/oidc/oidc.go similarity index 63% rename from internal/oidc/oidc.go rename to internal/federationdomain/oidc/oidc.go index 71bc914d..a314ac68 100644 --- a/internal/oidc/oidc.go +++ b/internal/federationdomain/oidc/oidc.go @@ -1,13 +1,13 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -// Package oidc contains common OIDC functionality needed by Pinniped. +// Package oidc contains common OIDC functionality needed by FederationDomains to implement +// downstream OIDC functionality. package oidc import ( "context" "crypto/subtle" - "errors" "fmt" "net/http" "net/url" @@ -18,13 +18,14 @@ import ( "github.com/ory/fosite/compose" errorsx "github.com/pkg/errors" - "go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/federationdomain/csrftoken" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/endpoints/tokenexchange" + "go.pinniped.dev/internal/federationdomain/formposthtml" + "go.pinniped.dev/internal/federationdomain/strategy" + "go.pinniped.dev/internal/federationdomain/timeouts" "go.pinniped.dev/internal/httputil/httperr" - "go.pinniped.dev/internal/oidc/csrftoken" - "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -102,81 +103,13 @@ type UpstreamStateParamData struct { FormatVersion string `json:"v"` } -type TimeoutsConfiguration struct { - // The length of time that our state param that we encrypt and pass to the upstream OIDC IDP should be considered - // valid. If a state param generated by the authorize endpoint is sent to the callback endpoint after this much - // time has passed, then the callback endpoint should reject it. This allows us to set a limit on how long - // the end user has to finish their login with the upstream IDP, including the time that it takes to fumble - // with password manager and two-factor authenticator apps, and also accounting for taking a coffee break while - // the browser is sitting at the upstream IDP's login page. - UpstreamStateParamLifespan time.Duration - - // How long an authcode issued by the callback endpoint is valid. This determines how much time the end user - // has to come back to exchange the authcode for tokens at the token endpoint. - AuthorizeCodeLifespan time.Duration - - // The lifetime of an downstream access token issued by the token endpoint. Access tokens should generally - // be fairly short-lived. - AccessTokenLifespan time.Duration - - // The lifetime of an downstream ID token issued by the token endpoint. This should generally be the same - // as the AccessTokenLifespan, or longer if it would be useful for the user's proof of identity to be valid - // for longer than their proof of authorization. - IDTokenLifespan time.Duration - - // The lifetime of an downstream refresh token issued by the token endpoint. This should generally be - // significantly longer than the access token lifetime, so it can be used to refresh the access token - // multiple times. Once the refresh token expires, the user's session is over and they will need - // to start a new authorization request, which will require them to log in again with the upstream IDP - // in their web browser. - RefreshTokenLifespan time.Duration - - // AuthorizationCodeSessionStorageLifetime is the length of time after which an authcode is allowed to be garbage - // collected from storage. Authcodes are kept in storage after they are redeemed to allow the system to mark the - // authcode as already used, so it can reject any future uses of the same authcode with special case handling which - // include revoking the access and refresh tokens associated with the session. Therefore, this should be - // significantly longer than the AuthorizeCodeLifespan, and there is probably no reason to make it longer than - // the sum of the AuthorizeCodeLifespan and the RefreshTokenLifespan. - AuthorizationCodeSessionStorageLifetime time.Duration - - // PKCESessionStorageLifetime is the length of time after which PKCE data is allowed to be garbage collected from - // storage. PKCE sessions are closely related to authorization code sessions. After the authcode is successfully - // redeemed, the PKCE session is explicitly deleted. After the authcode expires, the PKCE session is no longer needed, - // but it is not explicitly deleted. Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll - // avoid making it exactly the same as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it - // while it is being used. - PKCESessionStorageLifetime time.Duration - - // OIDCSessionStorageLifetime is the length of time after which the OIDC session data related to an authcode - // is allowed to be garbage collected from storage. Due to a bug in an underlying library, these are not explicitly - // deleted. Similar to the PKCE session, they are not needed anymore after the corresponding authcode has expired. - // Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll avoid making it exactly the same - // as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it while it is being used. - OIDCSessionStorageLifetime time.Duration - - // AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed - // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid - // or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan. - AccessTokenSessionStorageLifetime time.Duration - - // RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed - // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid. - // Therefore, this can be just slightly longer than the RefreshTokenLifespan. We'll avoid making it exactly the same - // as RefreshTokenLifespan to avoid any chance of the garbage collector deleting it while it is being used. - // If an expired token is still stored when the user tries to refresh it, then they will get a more specific - // error message telling them that the token is expired, rather than a more generic error that is returned - // when the token does not exist. If this is desirable, then the RefreshTokenSessionStorageLifetime can be made - // to be significantly larger than RefreshTokenLifespan, at the cost of slower cleanup. - RefreshTokenSessionStorageLifetime time.Duration -} - // Get the defaults for the Supervisor server. -func DefaultOIDCTimeoutsConfiguration() TimeoutsConfiguration { +func DefaultOIDCTimeoutsConfiguration() timeouts.Configuration { accessTokenLifespan := 2 * time.Minute authorizationCodeLifespan := 10 * time.Minute refreshTokenLifespan := 9 * time.Hour - return TimeoutsConfiguration{ + return timeouts.Configuration{ UpstreamStateParamLifespan: 90 * time.Minute, AuthorizeCodeLifespan: authorizationCodeLifespan, AccessTokenLifespan: accessTokenLifespan, @@ -195,7 +128,7 @@ func FositeOauth2Helper( issuer string, hmacSecretOfLengthAtLeast32Func func() []byte, jwksProvider jwks.DynamicJWKSProvider, - timeoutsConfiguration TimeoutsConfiguration, + timeoutsConfiguration timeouts.Configuration, ) fosite.OAuth2Provider { isRedirectURISecureStrict := func(_ context.Context, uri *url.URL) bool { return fosite.IsRedirectURISecureStrict(uri) @@ -237,15 +170,15 @@ func FositeOauth2Helper( oauthStore, &compose.CommonStrategy{ // Note that Fosite requires the HMAC secret to be at least 32 bytes. - CoreStrategy: newDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func), - OpenIDConnectTokenStrategy: newDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), + CoreStrategy: strategy.NewDynamicOauth2HMACStrategy(oauthConfig, hmacSecretOfLengthAtLeast32Func), + OpenIDConnectTokenStrategy: strategy.NewDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), }, compose.OAuth2AuthorizeExplicitFactory, compose.OAuth2RefreshTokenGrantFactory, compose.OpenIDConnectExplicitFactory, compose.OpenIDConnectRefreshFactory, compose.OAuth2PKCEFactory, - TokenExchangeFactory, // handle the "urn:ietf:params:oauth:grant-type:token-exchange" grant type + tokenexchange.HandlerFactory, // handle the "urn:ietf:params:oauth:grant-type:token-exchange" grant type ) return oAuth2Provider @@ -279,24 +212,6 @@ func FositeErrorForLog(err error) []interface{} { return keysAndValues } -type UpstreamOIDCIdentityProvidersLister interface { - GetOIDCIdentityProviders() []provider.UpstreamOIDCIdentityProviderI -} - -type UpstreamLDAPIdentityProvidersLister interface { - GetLDAPIdentityProviders() []provider.UpstreamLDAPIdentityProviderI -} - -type UpstreamActiveDirectoryIdentityProviderLister interface { - GetActiveDirectoryIdentityProviders() []provider.UpstreamLDAPIdentityProviderI -} - -type UpstreamIdentityProvidersLister interface { - UpstreamOIDCIdentityProvidersLister - UpstreamLDAPIdentityProvidersLister - UpstreamActiveDirectoryIdentityProviderLister -} - func GrantScopeIfRequested(authorizeRequester fosite.AuthorizeRequester, scopeName string) { if ScopeWasRequested(authorizeRequester, scopeName) { authorizeRequester.GrantScope(scopeName) @@ -377,41 +292,6 @@ func validateCSRFValue(state *UpstreamStateParamData, csrfCookieValue csrftoken. return nil } -// FindUpstreamIDPByNameAndType finds the requested IDP by name and type, or returns an error. -// Note that AD and LDAP IDPs both return the same interface type, but different ProviderTypes values. -func FindUpstreamIDPByNameAndType( - idpLister UpstreamIdentityProvidersLister, - upstreamName string, - upstreamType string, -) ( - provider.UpstreamOIDCIdentityProviderI, - provider.UpstreamLDAPIdentityProviderI, - psession.ProviderType, - error, -) { - switch upstreamType { - case string(v1alpha1.IDPTypeOIDC): - for _, p := range idpLister.GetOIDCIdentityProviders() { - if p.GetName() == upstreamName { - return p, nil, psession.ProviderTypeOIDC, nil - } - } - case string(v1alpha1.IDPTypeLDAP): - for _, p := range idpLister.GetLDAPIdentityProviders() { - if p.GetName() == upstreamName { - return nil, p, psession.ProviderTypeLDAP, nil - } - } - case string(v1alpha1.IDPTypeActiveDirectory): - for _, p := range idpLister.GetActiveDirectoryIdentityProviders() { - if p.GetName() == upstreamName { - return nil, p, psession.ProviderTypeActiveDirectory, nil - } - } - } - return nil, nil, "", errors.New("provider not found") -} - // WriteAuthorizeError writes an authorization error as it should be returned by the authorization endpoint and other // similar endpoints that are the end of the downstream authcode flow. Errors responses are written in the usual fosite style. func WriteAuthorizeError(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester, err error, isBrowserless bool) { diff --git a/internal/oidc/oidcclientvalidator/oidcclientvalidator.go b/internal/federationdomain/oidcclientvalidator/oidcclientvalidator.go similarity index 100% rename from internal/oidc/oidcclientvalidator/oidcclientvalidator.go rename to internal/federationdomain/oidcclientvalidator/oidcclientvalidator.go diff --git a/internal/federationdomain/resolvedprovider/resolved_provider.go b/internal/federationdomain/resolvedprovider/resolved_provider.go new file mode 100644 index 00000000..e2b43a86 --- /dev/null +++ b/internal/federationdomain/resolvedprovider/resolved_provider.go @@ -0,0 +1,30 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package resolvedprovider + +import ( + "go.pinniped.dev/internal/federationdomain/upstreamprovider" + "go.pinniped.dev/internal/idtransform" + "go.pinniped.dev/internal/psession" +) + +// FederationDomainResolvedOIDCIdentityProvider represents a FederationDomainIdentityProvider which has +// been resolved dynamically based on the currently loaded IDP CRs to include the provider.UpstreamOIDCIdentityProviderI +// and other metadata about the provider. +type FederationDomainResolvedOIDCIdentityProvider struct { + DisplayName string + Provider upstreamprovider.UpstreamOIDCIdentityProviderI + SessionProviderType psession.ProviderType + Transforms *idtransform.TransformationPipeline +} + +// FederationDomainResolvedLDAPIdentityProvider represents a FederationDomainIdentityProvider which has +// been resolved dynamically based on the currently loaded IDP CRs to include the provider.UpstreamLDAPIdentityProviderI +// and other metadata about the provider. +type FederationDomainResolvedLDAPIdentityProvider struct { + DisplayName string + Provider upstreamprovider.UpstreamLDAPIdentityProviderI + SessionProviderType psession.ProviderType + Transforms *idtransform.TransformationPipeline +} diff --git a/internal/oidc/dynamic_global_secret_config.go b/internal/federationdomain/storage/dynamic_global_secret_config.go similarity index 96% rename from internal/oidc/dynamic_global_secret_config.go rename to internal/federationdomain/storage/dynamic_global_secret_config.go index b9001eea..50f997c4 100644 --- a/internal/oidc/dynamic_global_secret_config.go +++ b/internal/federationdomain/storage/dynamic_global_secret_config.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package storage import ( "context" diff --git a/internal/oidc/kube_storage.go b/internal/federationdomain/storage/kube_storage.go similarity index 97% rename from internal/oidc/kube_storage.go rename to internal/federationdomain/storage/kube_storage.go index a197335e..e10ed60e 100644 --- a/internal/oidc/kube_storage.go +++ b/internal/federationdomain/storage/kube_storage.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package storage import ( "context" @@ -14,13 +14,14 @@ import ( corev1client "k8s.io/client-go/kubernetes/typed/core/v1" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" + "go.pinniped.dev/internal/federationdomain/clientregistry" + "go.pinniped.dev/internal/federationdomain/timeouts" "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/openidconnect" "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestorage/refreshtoken" "go.pinniped.dev/internal/fositestoragei" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidcclientsecretstorage" ) @@ -38,7 +39,7 @@ var _ fositestoragei.AllFositeStorage = &KubeStorage{} func NewKubeStorage( secrets corev1client.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface, - timeoutsConfiguration TimeoutsConfiguration, + timeoutsConfiguration timeouts.Configuration, minBcryptCost int, ) *KubeStorage { nowFunc := time.Now diff --git a/internal/oidc/nullstorage.go b/internal/federationdomain/storage/null_storage.go similarity index 96% rename from internal/oidc/nullstorage.go rename to internal/federationdomain/storage/null_storage.go index 61476f81..ae5b0ecc 100644 --- a/internal/oidc/nullstorage.go +++ b/internal/federationdomain/storage/null_storage.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package storage import ( "context" @@ -11,8 +11,8 @@ import ( "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/constable" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestoragei" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidcclientsecretstorage" ) diff --git a/internal/oidc/dynamic_oauth2_hmac_strategy.go b/internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy.go similarity index 81% rename from internal/oidc/dynamic_oauth2_hmac_strategy.go rename to internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy.go index d5456b67..200dd41c 100644 --- a/internal/oidc/dynamic_oauth2_hmac_strategy.go +++ b/internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package strategy import ( "context" @@ -11,6 +11,8 @@ import ( "github.com/ory/fosite/compose" "github.com/ory/fosite/handler/oauth2" errorsx "github.com/pkg/errors" + + "go.pinniped.dev/internal/federationdomain/storage" ) const ( @@ -24,7 +26,7 @@ const ( oryAuthcodePrefix = "ory_ac_" ) -// dynamicOauth2HMACStrategy is an oauth2.CoreStrategy that can dynamically load an HMAC key to sign +// DynamicOauth2HMACStrategy is an oauth2.CoreStrategy that can dynamically load an HMAC key to sign // stuff (access tokens, refresh tokens, and auth codes). We want this dynamic capability since our // controllers for loading FederationDomain's and signing keys run in parallel, and thus the signing key // might not be ready when an FederationDomain is otherwise ready. @@ -37,18 +39,18 @@ const ( // out of context, such as when accidentally committed to a GitHub repo. After we implemented the // custom prefix feature, fosite later added the same feature, but did not make the prefix customizable. // Therefore, this code has been updated to replace the fosite prefix with our custom prefix. -type dynamicOauth2HMACStrategy struct { +type DynamicOauth2HMACStrategy struct { fositeConfig *fosite.Config keyFunc func() []byte } -var _ oauth2.CoreStrategy = &dynamicOauth2HMACStrategy{} +var _ oauth2.CoreStrategy = &DynamicOauth2HMACStrategy{} -func newDynamicOauth2HMACStrategy( +func NewDynamicOauth2HMACStrategy( fositeConfig *fosite.Config, keyFunc func() []byte, -) *dynamicOauth2HMACStrategy { - return &dynamicOauth2HMACStrategy{ +) *DynamicOauth2HMACStrategy { + return &DynamicOauth2HMACStrategy{ fositeConfig: fositeConfig, keyFunc: keyFunc, } @@ -58,11 +60,11 @@ func replacePrefix(s, prefixToReplace, newPrefix string) string { return newPrefix + strings.TrimPrefix(s, prefixToReplace) } -func (s *dynamicOauth2HMACStrategy) AccessTokenSignature(ctx context.Context, token string) string { +func (s *DynamicOauth2HMACStrategy) AccessTokenSignature(ctx context.Context, token string) string { return s.delegate().AccessTokenSignature(ctx, token) } -func (s *dynamicOauth2HMACStrategy) GenerateAccessToken( +func (s *DynamicOauth2HMACStrategy) GenerateAccessToken( ctx context.Context, requester fosite.Requester, ) (string, string, error) { @@ -78,7 +80,7 @@ func (s *dynamicOauth2HMACStrategy) GenerateAccessToken( return token, sig, err } -func (s *dynamicOauth2HMACStrategy) ValidateAccessToken( +func (s *DynamicOauth2HMACStrategy) ValidateAccessToken( ctx context.Context, requester fosite.Requester, token string, @@ -90,11 +92,11 @@ func (s *dynamicOauth2HMACStrategy) ValidateAccessToken( return s.delegate().ValidateAccessToken(ctx, requester, replacePrefix(token, pinAccessTokenPrefix, oryAccessTokenPrefix)) } -func (s *dynamicOauth2HMACStrategy) RefreshTokenSignature(ctx context.Context, token string) string { +func (s *DynamicOauth2HMACStrategy) RefreshTokenSignature(ctx context.Context, token string) string { return s.delegate().RefreshTokenSignature(ctx, token) } -func (s *dynamicOauth2HMACStrategy) GenerateRefreshToken( +func (s *DynamicOauth2HMACStrategy) GenerateRefreshToken( ctx context.Context, requester fosite.Requester, ) (string, string, error) { @@ -110,7 +112,7 @@ func (s *dynamicOauth2HMACStrategy) GenerateRefreshToken( return token, sig, err } -func (s *dynamicOauth2HMACStrategy) ValidateRefreshToken( +func (s *DynamicOauth2HMACStrategy) ValidateRefreshToken( ctx context.Context, requester fosite.Requester, token string, @@ -122,11 +124,11 @@ func (s *dynamicOauth2HMACStrategy) ValidateRefreshToken( return s.delegate().ValidateRefreshToken(ctx, requester, replacePrefix(token, pinRefreshTokenPrefix, oryRefreshTokenPrefix)) } -func (s *dynamicOauth2HMACStrategy) AuthorizeCodeSignature(ctx context.Context, token string) string { +func (s *DynamicOauth2HMACStrategy) AuthorizeCodeSignature(ctx context.Context, token string) string { return s.delegate().AuthorizeCodeSignature(ctx, token) } -func (s *dynamicOauth2HMACStrategy) GenerateAuthorizeCode( +func (s *DynamicOauth2HMACStrategy) GenerateAuthorizeCode( ctx context.Context, requester fosite.Requester, ) (string, string, error) { @@ -142,7 +144,7 @@ func (s *dynamicOauth2HMACStrategy) GenerateAuthorizeCode( return authcode, sig, err } -func (s *dynamicOauth2HMACStrategy) ValidateAuthorizeCode( +func (s *DynamicOauth2HMACStrategy) ValidateAuthorizeCode( ctx context.Context, requester fosite.Requester, token string, @@ -154,6 +156,6 @@ func (s *dynamicOauth2HMACStrategy) ValidateAuthorizeCode( return s.delegate().ValidateAuthorizeCode(ctx, requester, replacePrefix(token, pinAuthcodePrefix, oryAuthcodePrefix)) } -func (s *dynamicOauth2HMACStrategy) delegate() *oauth2.HMACSHAStrategy { - return compose.NewOAuth2HMACStrategy(NewDynamicGlobalSecretConfig(s.fositeConfig, s.keyFunc)) +func (s *DynamicOauth2HMACStrategy) delegate() *oauth2.HMACSHAStrategy { + return compose.NewOAuth2HMACStrategy(storage.NewDynamicGlobalSecretConfig(s.fositeConfig, s.keyFunc)) } diff --git a/internal/oidc/dynamic_oauth2_hmac_strategy_test.go b/internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy_test.go similarity index 97% rename from internal/oidc/dynamic_oauth2_hmac_strategy_test.go rename to internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy_test.go index c2028a79..02bf861c 100644 --- a/internal/oidc/dynamic_oauth2_hmac_strategy_test.go +++ b/internal/federationdomain/strategy/dynamic_oauth2_hmac_strategy_test.go @@ -1,7 +1,7 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package strategy import ( "context" @@ -15,7 +15,7 @@ import ( ) func TestDynamicOauth2HMACStrategy_Signatures(t *testing.T) { - s := newDynamicOauth2HMACStrategy( + s := NewDynamicOauth2HMACStrategy( &fosite.Config{}, // defaults are good enough for this unit test func() []byte { return []byte("12345678901234567890123456789012") }, // 32 character secret key ) @@ -57,12 +57,12 @@ func TestDynamicOauth2HMACStrategy_Signatures(t *testing.T) { } func TestDynamicOauth2HMACStrategy_Generate(t *testing.T) { - s := newDynamicOauth2HMACStrategy( + s := NewDynamicOauth2HMACStrategy( &fosite.Config{}, // defaults are good enough for this unit test func() []byte { return []byte("12345678901234567890123456789012") }, // 32 character secret key ) - generateTokenErrorCausingStrategy := newDynamicOauth2HMACStrategy( + generateTokenErrorCausingStrategy := NewDynamicOauth2HMACStrategy( &fosite.Config{}, func() []byte { return []byte("too_short_causes_error") }, // secret key is below required 32 characters ) @@ -134,7 +134,7 @@ func TestDynamicOauth2HMACStrategy_Generate(t *testing.T) { } func TestDynamicOauth2HMACStrategy_Validate(t *testing.T) { - s := newDynamicOauth2HMACStrategy( + s := NewDynamicOauth2HMACStrategy( &fosite.Config{}, // defaults are good enough for this unit test func() []byte { return []byte("12345678901234567890123456789012") }, // 32 character secret key ) diff --git a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go b/internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy.go similarity index 79% rename from internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go rename to internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy.go index 80782882..463a450c 100644 --- a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy.go +++ b/internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package strategy import ( "context" @@ -14,11 +14,11 @@ import ( "github.com/ory/fosite/handler/openid" "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/plog" ) -// dynamicOpenIDConnectECDSAStrategy is an openid.OpenIDConnectTokenStrategy that can dynamically +// DynamicOpenIDConnectECDSAStrategy is an openid.OpenIDConnectTokenStrategy that can dynamically // load a signing key to issue ID tokens. We want this dynamic capability since our controllers for // loading FederationDomain's and signing keys run in parallel, and thus the signing key might not be // ready when an FederationDomain is otherwise ready. @@ -26,24 +26,24 @@ import ( // If we ever update FederationDomain's to hold their signing key, we might not need this type, since we // could have an invariant that routes to an FederationDomain's endpoints are only wired up if an // FederationDomain has a valid signing key. -type dynamicOpenIDConnectECDSAStrategy struct { +type DynamicOpenIDConnectECDSAStrategy struct { fositeConfig *fosite.Config jwksProvider jwks.DynamicJWKSProvider } -var _ openid.OpenIDConnectTokenStrategy = &dynamicOpenIDConnectECDSAStrategy{} +var _ openid.OpenIDConnectTokenStrategy = &DynamicOpenIDConnectECDSAStrategy{} -func newDynamicOpenIDConnectECDSAStrategy( +func NewDynamicOpenIDConnectECDSAStrategy( fositeConfig *fosite.Config, jwksProvider jwks.DynamicJWKSProvider, -) *dynamicOpenIDConnectECDSAStrategy { - return &dynamicOpenIDConnectECDSAStrategy{ +) *DynamicOpenIDConnectECDSAStrategy { + return &DynamicOpenIDConnectECDSAStrategy{ fositeConfig: fositeConfig, jwksProvider: jwksProvider, } } -func (s *dynamicOpenIDConnectECDSAStrategy) GenerateIDToken( +func (s *DynamicOpenIDConnectECDSAStrategy) GenerateIDToken( ctx context.Context, lifespan time.Duration, requester fosite.Requester, diff --git a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go b/internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy_test.go similarity index 95% rename from internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go rename to internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy_test.go index 3573f987..94bf676a 100644 --- a/internal/oidc/dynamic_open_id_connect_ecdsa_strategy_test.go +++ b/internal/federationdomain/strategy/dynamic_open_id_connect_ecdsa_strategy_test.go @@ -1,7 +1,7 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package oidc +package strategy import ( "context" @@ -20,7 +20,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" - "go.pinniped.dev/internal/oidc/jwks" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" "go.pinniped.dev/internal/testutil/oidctestutil" ) @@ -94,7 +94,7 @@ func TestDynamicOpenIDConnectECDSAStrategy(t *testing.T) { if test.jwksProvider != nil { test.jwksProvider(jwksProvider) } - s := newDynamicOpenIDConnectECDSAStrategy( + s := NewDynamicOpenIDConnectECDSAStrategy( &fosite.Config{IDTokenIssuer: test.issuer}, jwksProvider, ) diff --git a/internal/federationdomain/timeouts/timeouts_configuration.go b/internal/federationdomain/timeouts/timeouts_configuration.go new file mode 100644 index 00000000..9657eaa4 --- /dev/null +++ b/internal/federationdomain/timeouts/timeouts_configuration.go @@ -0,0 +1,74 @@ +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package timeouts + +import "time" + +type Configuration struct { + // The length of time that our state param that we encrypt and pass to the upstream OIDC IDP should be considered + // valid. If a state param generated by the authorize endpoint is sent to the callback endpoint after this much + // time has passed, then the callback endpoint should reject it. This allows us to set a limit on how long + // the end user has to finish their login with the upstream IDP, including the time that it takes to fumble + // with password manager and two-factor authenticator apps, and also accounting for taking a coffee break while + // the browser is sitting at the upstream IDP's login page. + UpstreamStateParamLifespan time.Duration + + // How long an authcode issued by the callback endpoint is valid. This determines how much time the end user + // has to come back to exchange the authcode for tokens at the token endpoint. + AuthorizeCodeLifespan time.Duration + + // The lifetime of an downstream access token issued by the token endpoint. Access tokens should generally + // be fairly short-lived. + AccessTokenLifespan time.Duration + + // The lifetime of an downstream ID token issued by the token endpoint. This should generally be the same + // as the AccessTokenLifespan, or longer if it would be useful for the user's proof of identity to be valid + // for longer than their proof of authorization. + IDTokenLifespan time.Duration + + // The lifetime of an downstream refresh token issued by the token endpoint. This should generally be + // significantly longer than the access token lifetime, so it can be used to refresh the access token + // multiple times. Once the refresh token expires, the user's session is over and they will need + // to start a new authorization request, which will require them to log in again with the upstream IDP + // in their web browser. + RefreshTokenLifespan time.Duration + + // AuthorizationCodeSessionStorageLifetime is the length of time after which an authcode is allowed to be garbage + // collected from storage. Authcodes are kept in storage after they are redeemed to allow the system to mark the + // authcode as already used, so it can reject any future uses of the same authcode with special case handling which + // include revoking the access and refresh tokens associated with the session. Therefore, this should be + // significantly longer than the AuthorizeCodeLifespan, and there is probably no reason to make it longer than + // the sum of the AuthorizeCodeLifespan and the RefreshTokenLifespan. + AuthorizationCodeSessionStorageLifetime time.Duration + + // PKCESessionStorageLifetime is the length of time after which PKCE data is allowed to be garbage collected from + // storage. PKCE sessions are closely related to authorization code sessions. After the authcode is successfully + // redeemed, the PKCE session is explicitly deleted. After the authcode expires, the PKCE session is no longer needed, + // but it is not explicitly deleted. Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll + // avoid making it exactly the same as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it + // while it is being used. + PKCESessionStorageLifetime time.Duration + + // OIDCSessionStorageLifetime is the length of time after which the OIDC session data related to an authcode + // is allowed to be garbage collected from storage. Due to a bug in an underlying library, these are not explicitly + // deleted. Similar to the PKCE session, they are not needed anymore after the corresponding authcode has expired. + // Therefore, this can be just slightly longer than the AuthorizeCodeLifespan. We'll avoid making it exactly the same + // as AuthorizeCodeLifespan to avoid any chance of the garbage collector deleting it while it is being used. + OIDCSessionStorageLifetime time.Duration + + // AccessTokenSessionStorageLifetime is the length of time after which an access token's session data is allowed + // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid + // or else the refresh flow will not work properly. So this must be longer than RefreshTokenLifespan. + AccessTokenSessionStorageLifetime time.Duration + + // RefreshTokenSessionStorageLifetime is the length of time after which a refresh token's session data is allowed + // to be garbage collected from storage. These must exist in storage for as long as the refresh token is valid. + // Therefore, this can be just slightly longer than the RefreshTokenLifespan. We'll avoid making it exactly the same + // as RefreshTokenLifespan to avoid any chance of the garbage collector deleting it while it is being used. + // If an expired token is still stored when the user tries to refresh it, then they will get a more specific + // error message telling them that the token is expired, rather than a more generic error that is returned + // when the token does not exist. If this is desirable, then the RefreshTokenSessionStorageLifetime can be made + // to be significantly larger than RefreshTokenLifespan, at the cost of slower cleanup. + RefreshTokenSessionStorageLifetime time.Duration +} diff --git a/internal/oidc/provider/dynamic_upstream_idp_provider.go b/internal/federationdomain/upstreamprovider/upsteam_provider.go similarity index 65% rename from internal/oidc/provider/dynamic_upstream_idp_provider.go rename to internal/federationdomain/upstreamprovider/upsteam_provider.go index ca26451e..7caeafc1 100644 --- a/internal/oidc/provider/dynamic_upstream_idp_provider.go +++ b/internal/federationdomain/upstreamprovider/upsteam_provider.go @@ -1,13 +1,11 @@ // Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -package provider +package upstreamprovider import ( "context" - "fmt" "net/url" - "sync" "golang.org/x/oauth2" "k8s.io/apimachinery/pkg/types" @@ -26,9 +24,22 @@ const ( AccessTokenType RevocableTokenType = "access_token" ) +// RefreshAttributes contains information about the user from the original login request +// and previous refreshes. +type RefreshAttributes struct { + Username string + Subject string + DN string + Groups []string + AdditionalAttributes map[string]string + GrantedScopes []string +} + type UpstreamOIDCIdentityProviderI interface { - // GetName returns a name for this upstream provider, which will be used as a component of the path for the - // callback endpoint hosted by the Supervisor. + // GetName returns a name for this upstream provider. The controller watching the OIDCIdentityProviders will + // set this to be the Name of the CR from its metadata. Note that this is different from the DisplayName configured + // in each FederationDomain that uses this provider, so this name is for internal use only, not for interacting + // with clients. Clients should not expect to see this name or send this name. GetName() string // GetClientID returns the OAuth client ID registered with the upstream provider to be used in the authorization code flow. @@ -111,92 +122,5 @@ type UpstreamLDAPIdentityProviderI interface { authenticators.UserAuthenticator // PerformRefresh performs a refresh against the upstream LDAP identity provider - PerformRefresh(ctx context.Context, storedRefreshAttributes RefreshAttributes) (groups []string, err error) -} - -// RefreshAttributes contains information about the user from the original login request -// and previous refreshes. -type RefreshAttributes struct { - Username string - Subject string - DN string - Groups []string - AdditionalAttributes map[string]string - GrantedScopes []string -} - -type DynamicUpstreamIDPProvider interface { - SetOIDCIdentityProviders(oidcIDPs []UpstreamOIDCIdentityProviderI) - GetOIDCIdentityProviders() []UpstreamOIDCIdentityProviderI - SetLDAPIdentityProviders(ldapIDPs []UpstreamLDAPIdentityProviderI) - GetLDAPIdentityProviders() []UpstreamLDAPIdentityProviderI - SetActiveDirectoryIdentityProviders(adIDPs []UpstreamLDAPIdentityProviderI) - GetActiveDirectoryIdentityProviders() []UpstreamLDAPIdentityProviderI -} - -type dynamicUpstreamIDPProvider struct { - oidcUpstreams []UpstreamOIDCIdentityProviderI - ldapUpstreams []UpstreamLDAPIdentityProviderI - activeDirectoryUpstreams []UpstreamLDAPIdentityProviderI - mutex sync.RWMutex -} - -func NewDynamicUpstreamIDPProvider() DynamicUpstreamIDPProvider { - return &dynamicUpstreamIDPProvider{ - oidcUpstreams: []UpstreamOIDCIdentityProviderI{}, - ldapUpstreams: []UpstreamLDAPIdentityProviderI{}, - activeDirectoryUpstreams: []UpstreamLDAPIdentityProviderI{}, - } -} - -func (p *dynamicUpstreamIDPProvider) SetOIDCIdentityProviders(oidcIDPs []UpstreamOIDCIdentityProviderI) { - p.mutex.Lock() // acquire a write lock - defer p.mutex.Unlock() - p.oidcUpstreams = oidcIDPs -} - -func (p *dynamicUpstreamIDPProvider) GetOIDCIdentityProviders() []UpstreamOIDCIdentityProviderI { - p.mutex.RLock() // acquire a read lock - defer p.mutex.RUnlock() - return p.oidcUpstreams -} - -func (p *dynamicUpstreamIDPProvider) SetLDAPIdentityProviders(ldapIDPs []UpstreamLDAPIdentityProviderI) { - p.mutex.Lock() // acquire a write lock - defer p.mutex.Unlock() - p.ldapUpstreams = ldapIDPs -} - -func (p *dynamicUpstreamIDPProvider) GetLDAPIdentityProviders() []UpstreamLDAPIdentityProviderI { - p.mutex.RLock() // acquire a read lock - defer p.mutex.RUnlock() - return p.ldapUpstreams -} - -func (p *dynamicUpstreamIDPProvider) SetActiveDirectoryIdentityProviders(adIDPs []UpstreamLDAPIdentityProviderI) { - p.mutex.Lock() // acquire a write lock - defer p.mutex.Unlock() - p.activeDirectoryUpstreams = adIDPs -} - -func (p *dynamicUpstreamIDPProvider) GetActiveDirectoryIdentityProviders() []UpstreamLDAPIdentityProviderI { - p.mutex.RLock() // acquire a read lock - defer p.mutex.RUnlock() - return p.activeDirectoryUpstreams -} - -type RetryableRevocationError struct { - wrapped error -} - -func NewRetryableRevocationError(wrapped error) RetryableRevocationError { - return RetryableRevocationError{wrapped: wrapped} -} - -func (e RetryableRevocationError) Error() string { - return fmt.Sprintf("retryable revocation error: %v", e.wrapped) -} - -func (e RetryableRevocationError) Unwrap() error { - return e.wrapped + PerformRefresh(ctx context.Context, storedRefreshAttributes RefreshAttributes, idpDisplayName string) (groups []string, err error) } diff --git a/internal/fositestorage/accesstoken/accesstoken.go b/internal/fositestorage/accesstoken/accesstoken.go index a70f1a70..04247889 100644 --- a/internal/fositestorage/accesstoken/accesstoken.go +++ b/internal/fositestorage/accesstoken/accesstoken.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package accesstoken @@ -16,8 +16,8 @@ import ( "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" ) @@ -31,7 +31,8 @@ const ( // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. // Version 3 is when we added the Username field to the psession.CustomSessionData. // Version 4 is when fosite added json tags to their openid.DefaultSession struct. - accessTokenStorageVersion = "4" + // Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData. + accessTokenStorageVersion = "5" ) type RevocationStorage interface { diff --git a/internal/fositestorage/accesstoken/accesstoken_test.go b/internal/fositestorage/accesstoken/accesstoken_test.go index 9f8e9da0..2e571e9a 100644 --- a/internal/fositestorage/accesstoken/accesstoken_test.go +++ b/internal/fositestorage/accesstoken/accesstoken_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package accesstoken @@ -22,7 +22,7 @@ import ( coretesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" - "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" ) @@ -54,7 +54,7 @@ func TestAccessTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -123,7 +123,7 @@ func TestAccessTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -196,7 +196,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAccessTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 4") + require.EqualError(t, err, "access token request data has wrong version: access token session for fancy-signature has version not-the-right-version instead of 5") } func TestNilSessionRequest(t *testing.T) { @@ -214,7 +214,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"4"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", @@ -298,13 +298,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", }, wantSession: &Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ ID: "abcd-1", Client: &clientregistry.Client{}, @@ -316,10 +316,12 @@ func TestReadFromSecret(t *testing.T) { Headers: &jwt.Headers{Extra: map[string]interface{}{"myheader": "foo"}}, }, Custom: &psession.CustomSessionData{ - Username: "fake-username", - ProviderUID: "fake-provider-uid", - ProviderName: "fake-provider-name", - ProviderType: "fake-provider-type", + Username: "fake-username", + ProviderUID: "fake-provider-uid", + ProviderName: "fake-provider-name", + ProviderType: "fake-provider-type", + UpstreamUsername: "fake-upstream-username", + UpstreamGroups: []string{"fake-upstream-group1", "fake-upstream-group2"}, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: "fake-upstream-refresh-token", }, @@ -339,7 +341,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-access-token", @@ -362,7 +364,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/access-token", }, - wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 4", + wantErr: "access token request data has wrong version: access token session has version wrong-version-here instead of 5", }, { name: "missing request", @@ -375,7 +377,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/access-token", diff --git a/internal/fositestorage/authorizationcode/authorizationcode.go b/internal/fositestorage/authorizationcode/authorizationcode.go index fd34ce2c..c2fe859d 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode.go +++ b/internal/fositestorage/authorizationcode/authorizationcode.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package authorizationcode @@ -17,8 +17,8 @@ import ( "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" ) @@ -32,7 +32,8 @@ const ( // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. // Version 3 is when we added the Username field to the psession.CustomSessionData. // Version 4 is when fosite added json tags to their openid.DefaultSession struct. - authorizeCodeStorageVersion = "4" + // Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData. + authorizeCodeStorageVersion = "5" ) var _ oauth2.AuthorizeCodeStorage = &authorizeCodeStorage{} @@ -372,42 +373,48 @@ const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{ }, "custom": { "username": "Ĝ眧Ĭ", - "providerUID": "ʼn2ƋŢ觛ǂ焺nŐǛ", - "providerName": "ɥ闣ʬ橳(ý綃ʃʚƟ覣k眐4", - "providerType": "ȣ掘ʃƸ澺淗a紽ǒ|鰽", + "upstreamUsername": "ʼn2ƋŢ觛ǂ焺nŐǛ", + "upstreamGroups": [ + "闣ʬ橳(ý綃ʃʚƟ覣k眐4Ĉt", + "ʃƸ澺淗a紽ǒ|鰽ŋ猊Ia瓕巈環_ɑ" + ], + "providerUID": "ƴŤȱʀļÂ?墖", + "providerName": "7就伒犘c钡", + "providerType": "k|鬌R蜚蠣麹概÷驣7Ʀ澉1æɽ誮", "warnings": [ - "t毇妬\u003e6鉢緋uƴŤȱʀļÂ", - "虝27就伒犘c钡ɏȫ齁š" + "鷞aŚB碠k9帴ʘ赱", + "ď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ¡圔" ], "oidc": { - "upstreamRefreshToken": "OpKȱ藚ɏ¬Ê蒭堜]ȗ韚ʫ繕ȫ碰+ʫ", - "upstreamAccessToken": "k9帴", - "upstreamSubject": "磊ůď逳鞪?3)藵睋邔\u0026Ű惫蜀Ģ", - "upstreamIssuer": "4İ" + "upstreamRefreshToken": "墀jMʥ", + "upstreamAccessToken": "+î艔垎0", + "upstreamSubject": "ĝ", + "upstreamIssuer": "ǢIȽ" }, "ldap": { - "userDN": "×", + "userDN": "士b", "extraRefreshAttributes": { - "ʥ笿0D": "s" + "O灞浛a齙\\蹼偦歛ơ 皦pSǬŝ": "Džķ?吭匞饫Ƽĝ\"zvư", + "f跞@)¿,ɭS隑ip偶宾儮猷": "面@yȝƋ鬯犦獢9c5¤" } }, "activedirectory": { - "userDN": "ĝ", + "userDN": "置b", "extraRefreshAttributes": { - "IȽ齤士bEǎ": "跞@)¿,ɭS隑ip偶宾儮猷V麹", - "ȝƋ鬯犦獢9c5¤.岵": "浛a齙\\蹼偦歛" + "MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{鼐": "$+溪ŸȢŒų崓ļ憽", + "ĩŦʀ宍D挟": "q萮左/篣AÚƄŕ~čfVLPC諡}", + "姧骦:駝重EȫʆɵʮGɃ": "囤1+,Ȳ齠@ɍB鳛Nč乿ƔǴę鏶" } } } }, "requestedAudience": [ - " 皦pSǬŝ社Vƅȭǝ*擦28Dž", - "vư" + "ň" ], "grantedAudience": [ - "置b", - "筫MN\u0026錝D肁Ŷɽ蔒PR}Ųʓl{" + "â融貵捠ʼn", + "d鞕ȸ腿tʏƲ%}ſ¯Ɣ 籌Tǘ乚Ȥ2" ] }, - "version": "4" + "version": "5" }` diff --git a/internal/fositestorage/authorizationcode/authorizationcode_test.go b/internal/fositestorage/authorizationcode/authorizationcode_test.go index 05f20671..69912765 100644 --- a/internal/fositestorage/authorizationcode/authorizationcode_test.go +++ b/internal/fositestorage/authorizationcode/authorizationcode_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package authorizationcode @@ -34,8 +34,8 @@ import ( kubetesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" ) @@ -66,7 +66,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"active":true,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -86,7 +86,7 @@ func TestAuthorizationCodeStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"active":false,"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -204,7 +204,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetAuthorizeCodeSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 4") + require.EqualError(t, err, "authorization request data has wrong version: authorization code session for fancy-signature has version not-the-right-version instead of 5") } func TestNilSessionRequest(t *testing.T) { @@ -219,7 +219,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"4", "active": true}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value", "version":"5", "active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", @@ -386,7 +386,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { // set these to match CreateAuthorizeCodeSession so that .JSONEq works validSession.Active = true - validSession.Version = "4" + validSession.Version = "5" // update this when you update the storage version in the production code validSessionJSONBytes, err := json.MarshalIndent(validSession, "", "\t") require.NoError(t, err) @@ -395,7 +395,7 @@ func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) { // the fuzzed session and storage session should have identical JSON require.JSONEq(t, authorizeCodeSessionJSONFromFuzzing, authorizeCodeSessionJSONFromStorage) - // t.Log("actual value from fuzzing", authorizeCodeSessionJSONFromFuzzing) // can be useful when updating expected value + t.Log("actual value from fuzzing", authorizeCodeSessionJSONFromFuzzing) // can be useful when updating expected value // while the fuzzer will panic if AuthorizeRequest changes in a way that cannot be fuzzed, // if it adds a new field that can be fuzzed, this check will fail @@ -421,13 +421,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", }, wantSession: &Session{ - Version: "4", + Version: "5", Active: true, Request: &fosite.Request{ ID: "abcd-1", @@ -440,10 +440,12 @@ func TestReadFromSecret(t *testing.T) { Headers: &jwt.Headers{Extra: map[string]interface{}{"myheader": "foo"}}, }, Custom: &psession.CustomSessionData{ - Username: "fake-username", - ProviderUID: "fake-provider-uid", - ProviderName: "fake-provider-name", - ProviderType: "fake-provider-type", + Username: "fake-username", + ProviderUID: "fake-provider-uid", + ProviderName: "fake-provider-name", + ProviderType: "fake-provider-type", + UpstreamUsername: "fake-upstream-username", + UpstreamGroups: []string{"fake-upstream-group1", "fake-upstream-group2"}, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: "fake-upstream-refresh-token", }, @@ -463,7 +465,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"3","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-authcode", @@ -486,7 +488,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/authcode", }, - wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 4", + wantErr: "authorization request data has wrong version: authorization code session has version wrong-version-here instead of 5", }, { name: "missing request", @@ -499,7 +501,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/authcode", diff --git a/internal/fositestorage/fositestorage.go b/internal/fositestorage/fositestorage.go index af99caed..c15c8c90 100644 --- a/internal/fositestorage/fositestorage.go +++ b/internal/fositestorage/fositestorage.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package fositestorage @@ -7,7 +7,7 @@ import ( "github.com/ory/fosite" "go.pinniped.dev/internal/constable" - "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/psession" ) diff --git a/internal/fositestorage/openidconnect/openidconnect.go b/internal/fositestorage/openidconnect/openidconnect.go index d020e885..c2aa553d 100644 --- a/internal/fositestorage/openidconnect/openidconnect.go +++ b/internal/fositestorage/openidconnect/openidconnect.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package openidconnect @@ -16,8 +16,8 @@ import ( "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" ) @@ -32,7 +32,8 @@ const ( // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. // Version 3 is when we added the Username field to the psession.CustomSessionData. // Version 4 is when fosite added json tags to their openid.DefaultSession struct. - oidcStorageVersion = "4" + // Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData. + oidcStorageVersion = "5" ) var _ openid.OpenIDConnectRequestStorage = &openIDConnectRequestStorage{} diff --git a/internal/fositestorage/openidconnect/openidconnect_test.go b/internal/fositestorage/openidconnect/openidconnect_test.go index 7297710b..e278deea 100644 --- a/internal/fositestorage/openidconnect/openidconnect_test.go +++ b/internal/fositestorage/openidconnect/openidconnect_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package openidconnect @@ -21,7 +21,7 @@ import ( coretesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" - "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" ) @@ -52,7 +52,7 @@ func TestOpenIdConnectStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", @@ -137,7 +137,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetOpenIDConnectSession(ctx, "fancy-code.fancy-signature", nil) - require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 4") + require.EqualError(t, err, "oidc request data has wrong version: oidc session for fancy-signature has version not-the-right-version instead of 5") } func TestNilSessionRequest(t *testing.T) { @@ -152,7 +152,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"4"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/oidc", diff --git a/internal/fositestorage/pkce/pkce.go b/internal/fositestorage/pkce/pkce.go index 92fe9f83..6cea9a85 100644 --- a/internal/fositestorage/pkce/pkce.go +++ b/internal/fositestorage/pkce/pkce.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package pkce @@ -15,8 +15,8 @@ import ( "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" ) @@ -30,7 +30,8 @@ const ( // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. // Version 3 is when we added the Username field to the psession.CustomSessionData. // Version 4 is when fosite added json tags to their openid.DefaultSession struct. - pkceStorageVersion = "4" + // Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData. + pkceStorageVersion = "5" ) var _ pkce.PKCERequestStorage = &pkceStorage{} diff --git a/internal/fositestorage/pkce/pkce_test.go b/internal/fositestorage/pkce/pkce_test.go index 46461611..bc424593 100644 --- a/internal/fositestorage/pkce/pkce_test.go +++ b/internal/fositestorage/pkce/pkce_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package pkce @@ -21,7 +21,7 @@ import ( coretesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" - "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" ) @@ -52,7 +52,7 @@ func TestPKCEStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", @@ -140,7 +140,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetPKCERequestSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 4") + require.EqualError(t, err, "pkce request data has wrong version: pkce session for fancy-signature has version not-the-right-version instead of 5") } func TestNilSessionRequest(t *testing.T) { @@ -158,7 +158,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"4"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/pkce", diff --git a/internal/fositestorage/refreshtoken/refreshtoken.go b/internal/fositestorage/refreshtoken/refreshtoken.go index 28c2b5cb..d3abdc4f 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken.go +++ b/internal/fositestorage/refreshtoken/refreshtoken.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package refreshtoken @@ -16,8 +16,8 @@ import ( "go.pinniped.dev/internal/constable" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/psession" ) @@ -31,7 +31,8 @@ const ( // Version 2 is when we switched to storing psession.PinnipedSession inside the fosite request. // Version 3 is when we added the Username field to the psession.CustomSessionData. // Version 4 is when fosite added json tags to their openid.DefaultSession struct. - refreshTokenStorageVersion = "4" + // Version 5 is when we added the UpstreamUsername and UpstreamGroups fields to psession.CustomSessionData. + refreshTokenStorageVersion = "5" ) type RevocationStorage interface { diff --git a/internal/fositestorage/refreshtoken/refreshtoken_test.go b/internal/fositestorage/refreshtoken/refreshtoken_test.go index e785347d..24da03a1 100644 --- a/internal/fositestorage/refreshtoken/refreshtoken_test.go +++ b/internal/fositestorage/refreshtoken/refreshtoken_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package refreshtoken @@ -22,7 +22,7 @@ import ( coretesting "k8s.io/client-go/testing" clocktesting "k8s.io/utils/clock/testing" - "go.pinniped.dev/internal/oidc/clientregistry" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" ) @@ -53,7 +53,7 @@ func TestRefreshTokenStorage(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -123,7 +123,7 @@ func TestRefreshTokenStorageRevocation(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -178,7 +178,7 @@ func TestRefreshTokenStorageRevokeRefreshTokenMaybeGracePeriod(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"4"}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"fosite":{"id_token_claims":null,"headers":null,"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","warnings":null,"oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token","upstreamAccessToken":"","upstreamSubject":"some-subject","upstreamIssuer":"some-issuer"}}},"requestedAudience":null,"grantedAudience":null},"version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -252,7 +252,7 @@ func TestWrongVersion(t *testing.T) { _, err = storage.GetRefreshTokenSession(ctx, "fancy-signature", nil) - require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 4") + require.EqualError(t, err, "refresh token request data has wrong version: refresh token session for fancy-signature has version not-the-right-version instead of 5") } func TestNilSessionRequest(t *testing.T) { @@ -270,7 +270,7 @@ func TestNilSessionRequest(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"4"}`), + "pinniped-storage-data": []byte(`{"nonsense-key": "nonsense-value","version":"5"}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", @@ -354,13 +354,13 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1","session":{"fosite":{"id_token_claims":{"jti": "xyz"},"headers":{"extra":{"myheader": "foo"}},"expires_at":null,"username":"snorlax","subject":"panda"},"custom":{"username":"fake-username","upstreamUsername":"fake-upstream-username","upstreamGroups":["fake-upstream-group1","fake-upstream-group2"],"providerUID":"fake-provider-uid","providerName":"fake-provider-name","providerType":"fake-provider-type","oidc":{"upstreamRefreshToken":"fake-upstream-refresh-token"}}}},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", }, wantSession: &Session{ - Version: "4", + Version: "5", Request: &fosite.Request{ ID: "abcd-1", Client: &clientregistry.Client{}, @@ -372,10 +372,12 @@ func TestReadFromSecret(t *testing.T) { Headers: &jwt.Headers{Extra: map[string]interface{}{"myheader": "foo"}}, }, Custom: &psession.CustomSessionData{ - Username: "fake-username", - ProviderUID: "fake-provider-uid", - ProviderName: "fake-provider-name", - ProviderType: "fake-provider-type", + Username: "fake-username", + ProviderUID: "fake-provider-uid", + ProviderName: "fake-provider-name", + ProviderType: "fake-provider-type", + UpstreamUsername: "fake-upstream-username", + UpstreamGroups: []string{"fake-upstream-group1", "fake-upstream-group2"}, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: "fake-upstream-refresh-token", }, @@ -395,7 +397,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"request":{"id":"abcd-1"},"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/not-refresh-token", @@ -418,7 +420,7 @@ func TestReadFromSecret(t *testing.T) { }, Type: "storage.pinniped.dev/refresh-token", }, - wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 4", + wantErr: "refresh token request data has wrong version: refresh token session has version wrong-version-here instead of 5", }, { name: "missing request", @@ -431,7 +433,7 @@ func TestReadFromSecret(t *testing.T) { }, }, Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"4","active": true}`), + "pinniped-storage-data": []byte(`{"version":"5","active": true}`), "pinniped-storage-version": []byte("1"), }, Type: "storage.pinniped.dev/refresh-token", diff --git a/internal/idtransform/identity_transformations.go b/internal/idtransform/identity_transformations.go new file mode 100644 index 00000000..2518b8e2 --- /dev/null +++ b/internal/idtransform/identity_transformations.go @@ -0,0 +1,104 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package idtransform defines upstream-to-downstream identity transformations which could be +// implemented using various approaches or languages. +package idtransform + +import ( + "context" + "fmt" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/util/sets" +) + +// TransformationResult is the result of evaluating a transformation against some inputs. +type TransformationResult struct { + Username string // the new username for an allowed auth + Groups []string // the new group names for an allowed auth + AuthenticationAllowed bool // when false, disallow this authentication attempt + RejectedAuthenticationMessage string // should be set when AuthenticationAllowed is false +} + +// IdentityTransformation is an individual identity transformation which can be evaluated. +type IdentityTransformation interface { + Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) + + // Source returns some representation of the original source code of the transformation, which is + // useful for tests to be able to check that a compiled transformation came from the right source. + Source() interface{} +} + +// TransformationPipeline is a list of identity transforms, which can be evaluated in order against some given input +// values. +type TransformationPipeline struct { + transforms []IdentityTransformation +} + +// NewTransformationPipeline creates an empty TransformationPipeline. +func NewTransformationPipeline() *TransformationPipeline { + return &TransformationPipeline{transforms: []IdentityTransformation{}} +} + +// AppendTransformation adds a transformation to the end of the list of transformations for this pipeline. +// This is not thread-safe, so be sure to add all transformations from a single goroutine before using Evaluate +// from multiple goroutines. +func (p *TransformationPipeline) AppendTransformation(t IdentityTransformation) { + p.transforms = append(p.transforms, t) +} + +// Evaluate runs the transformation pipeline for a given input identity. It returns a potentially transformed or +// rejected identity, or an error. If any transformation in the list rejects the authentication, then the list is +// short-circuited but no error is returned. Only unexpected errors are returned as errors. This is safe to call +// from multiple goroutines. +func (p *TransformationPipeline) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + if groups == nil { + groups = []string{} + } + + accumulatedResult := &TransformationResult{ + Username: username, + Groups: groups, + AuthenticationAllowed: true, + } + + for i, transform := range p.transforms { + var err error + accumulatedResult, err = transform.Evaluate(ctx, accumulatedResult.Username, accumulatedResult.Groups) + if err != nil { + // There was an unexpected error evaluating a transformation. + return nil, fmt.Errorf("identity transformation at index %d: %w", i, err) + } + if !accumulatedResult.AuthenticationAllowed { + // Auth has been rejected by a policy. Stop evaluating the rest of the transformations. + return accumulatedResult, nil + } + if strings.TrimSpace(accumulatedResult.Username) == "" { + return nil, fmt.Errorf("identity transformation returned an empty username, which is not allowed") + } + if accumulatedResult.Groups == nil { + return nil, fmt.Errorf("identity transformation returned a null list of groups, which is not allowed") + } + } + + accumulatedResult.Groups = sortAndUniq(accumulatedResult.Groups) + + // There were no unexpected errors and no policy which rejected auth. + return accumulatedResult, nil +} + +func (p *TransformationPipeline) Source() []interface{} { + result := []interface{}{} + for _, transform := range p.transforms { + result = append(result, transform.Source()) + } + return result +} + +func sortAndUniq(s []string) []string { + unique := sets.New(s...).UnsortedList() + sort.Strings(unique) + return unique +} diff --git a/internal/idtransform/identity_transformations_test.go b/internal/idtransform/identity_transformations_test.go new file mode 100644 index 00000000..80d8f2df --- /dev/null +++ b/internal/idtransform/identity_transformations_test.go @@ -0,0 +1,340 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package idtransform + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +type fakeNoopTransformer struct{} + +func (a fakeNoopTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + return &TransformationResult{ + Username: username, + Groups: groups, + AuthenticationAllowed: true, + RejectedAuthenticationMessage: "none", + }, nil +} + +func (a fakeNoopTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeNilGroupTransformer struct{} + +func (a fakeNilGroupTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + return &TransformationResult{ + Username: username, + Groups: nil, + AuthenticationAllowed: true, + RejectedAuthenticationMessage: "none", + }, nil +} + +func (a fakeNilGroupTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeAppendStringTransformer struct{} + +func (a fakeAppendStringTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + newGroups := []string{} + for _, group := range groups { + newGroups = append(newGroups, group+":transformed") + } + return &TransformationResult{ + Username: username + ":transformed", + Groups: newGroups, + AuthenticationAllowed: true, + RejectedAuthenticationMessage: "none", + }, nil +} + +func (a fakeAppendStringTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeDeleteUsernameAndGroupsTransformer struct{} + +func (a fakeDeleteUsernameAndGroupsTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + return &TransformationResult{ + Username: "", + Groups: []string{}, + AuthenticationAllowed: true, + RejectedAuthenticationMessage: "none", + }, nil +} + +func (a fakeDeleteUsernameAndGroupsTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeAuthenticationDisallowedTransformer struct{} + +func (a fakeAuthenticationDisallowedTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + newGroups := []string{} + for _, group := range groups { + newGroups = append(newGroups, group+":disallowed") + } + return &TransformationResult{ + Username: username + ":disallowed", + Groups: newGroups, + AuthenticationAllowed: false, + RejectedAuthenticationMessage: "no authentication is allowed", + }, nil +} + +func (a fakeAuthenticationDisallowedTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeErrorTransformer struct{} + +func (a fakeErrorTransformer) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + return &TransformationResult{}, errors.New("unexpected catastrophic error") +} + +func (a fakeErrorTransformer) Source() interface{} { + return nil // not needed for this test +} + +type fakeTransformerWithSource struct { + source string +} + +func (a fakeTransformerWithSource) Evaluate(ctx context.Context, username string, groups []string) (*TransformationResult, error) { + return nil, nil // not needed for this test +} + +func (a fakeTransformerWithSource) Source() interface{} { + return a.source +} + +func TestTransformationPipelineEvaluation(t *testing.T) { + tests := []struct { + name string + username string + groups []string + transforms []IdentityTransformation + wantUsername string + wantGroups []string + wantAuthenticationAllowed bool + wantRejectionAuthenticationMessage string + wantError string + }{ + { + name: "single transformation applied successfully", + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + }, + username: "foo", + groups: []string{ + "foobar", + "foobaz", + }, + wantUsername: "foo:transformed", + wantGroups: []string{ + "foobar:transformed", + "foobaz:transformed", + }, + wantAuthenticationAllowed: true, + wantRejectionAuthenticationMessage: "none", + }, + { + name: "group results are sorted and made unique", + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + }, + username: "foo", + groups: []string{ + "b", + "a", + "b", + "a", + "c", + "b", + }, + wantUsername: "foo:transformed", + wantGroups: []string{ + "a:transformed", + "b:transformed", + "c:transformed", + }, + wantAuthenticationAllowed: true, + wantRejectionAuthenticationMessage: "none", + }, + { + name: "multiple transformations applied successfully", + username: "foo", + groups: []string{ + "foobar", + "foobaz", + }, + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + fakeAppendStringTransformer{}, + }, + wantUsername: "foo:transformed:transformed", + wantGroups: []string{ + "foobar:transformed:transformed", + "foobaz:transformed:transformed", + }, + wantAuthenticationAllowed: true, + wantRejectionAuthenticationMessage: "none", + }, + { + name: "single transformation results in AuthenticationAllowed:false", + username: "foo", + groups: []string{ + "foobar", + }, + transforms: []IdentityTransformation{ + fakeAuthenticationDisallowedTransformer{}, + }, + wantUsername: "foo:disallowed", + wantGroups: []string{"foobar:disallowed"}, + wantAuthenticationAllowed: false, + wantRejectionAuthenticationMessage: "no authentication is allowed", + }, + { + name: "multiple transformations results in AuthenticationAllowed:false but earlier transforms are successful", + username: "foo", + groups: []string{ + "foobar", + }, + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + fakeAuthenticationDisallowedTransformer{}, + // this transformation will not be run because the previous exits the pipeline + fakeAppendStringTransformer{}, + }, + wantUsername: "foo:transformed:disallowed", + wantGroups: []string{"foobar:transformed:disallowed"}, + wantAuthenticationAllowed: false, + wantRejectionAuthenticationMessage: "no authentication is allowed", + }, + { + name: "unexpected error at index", + username: "foo", + groups: []string{ + "foobar", + }, + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + fakeErrorTransformer{}, + fakeAppendStringTransformer{}, + }, + wantError: "identity transformation at index 1: unexpected catastrophic error", + }, + { + name: "empty username not allowed", + username: "foo", + transforms: []IdentityTransformation{ + fakeDeleteUsernameAndGroupsTransformer{}, + }, + wantError: "identity transformation returned an empty username, which is not allowed", + }, + { + name: "whitespace username not allowed", + username: " \t\n\r ", + transforms: []IdentityTransformation{ + fakeNoopTransformer{}, + }, + wantError: "identity transformation returned an empty username, which is not allowed", + }, + { + name: "identity transformation which returns an empty list of groups is allowed", + username: "foo", + groups: []string{}, + transforms: []IdentityTransformation{ + fakeAppendStringTransformer{}, + }, + wantUsername: "foo:transformed", + wantGroups: []string{}, + wantAuthenticationAllowed: true, + wantRejectionAuthenticationMessage: "none", + }, + { + name: "nil passed in for groups will be automatically converted to an empty list", + username: "foo", + groups: nil, + transforms: []IdentityTransformation{ + fakeNoopTransformer{}, + }, + wantUsername: "foo", + wantGroups: []string{}, + wantAuthenticationAllowed: true, + wantRejectionAuthenticationMessage: "none", + }, + { + name: "any transformation returning nil for the list of groups will cause an error", + username: "foo", + groups: []string{ + "these.will.be.converted.to.nil", + }, + transforms: []IdentityTransformation{ + fakeNilGroupTransformer{}, + }, + wantError: "identity transformation returned a null list of groups, which is not allowed", + }, + { + name: "no transformations is allowed", + username: "foo", + groups: []string{"bar", "baz"}, + transforms: []IdentityTransformation{}, + wantUsername: "foo", + wantGroups: []string{"bar", "baz"}, + wantAuthenticationAllowed: true, + // since no transformations run, this will be empty string + wantRejectionAuthenticationMessage: "", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pipeline := NewTransformationPipeline() + + for _, transform := range tt.transforms { + pipeline.AppendTransformation(transform) + } + + result, err := pipeline.Evaluate(context.Background(), tt.username, tt.groups) + + if tt.wantError != "" { + require.EqualError(t, err, tt.wantError) + require.Nil(t, result) + return + } + + require.NoError(t, err, "got an unexpected evaluation error") + require.Equal(t, tt.wantUsername, result.Username) + require.Equal(t, tt.wantGroups, result.Groups) + require.Equal(t, tt.wantAuthenticationAllowed, result.AuthenticationAllowed) + require.Equal(t, tt.wantRejectionAuthenticationMessage, result.RejectedAuthenticationMessage) + }) + } +} + +func TestTransformationSource(t *testing.T) { + pipeline := NewTransformationPipeline() + + for _, transform := range []IdentityTransformation{ + &fakeTransformerWithSource{source: "foo"}, + &fakeTransformerWithSource{source: "bar"}, + &fakeTransformerWithSource{source: "baz"}, + } { + pipeline.AppendTransformation(transform) + } + + require.Equal(t, []interface{}{"foo", "bar", "baz"}, pipeline.Source()) + require.NotEqual(t, []interface{}{"foo", "something-else", "baz"}, pipeline.Source()) +} diff --git a/internal/mocks/issuermocks/generate.go b/internal/mocks/issuermocks/generate.go index 7d0c9937..c770c927 100644 --- a/internal/mocks/issuermocks/generate.go +++ b/internal/mocks/issuermocks/generate.go @@ -1,6 +1,6 @@ -// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package issuermocks -//go:generate go run -v github.com/golang/mock/mockgen -destination=issuermocks.go -package=issuermocks -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/issuer ClientCertIssuer +//go:generate go run -v github.com/golang/mock/mockgen -destination=issuermocks.go -package=issuermocks -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/clientcertissuer ClientCertIssuer diff --git a/internal/mocks/issuermocks/issuermocks.go b/internal/mocks/issuermocks/issuermocks.go index 6867a2a0..2edd2848 100644 --- a/internal/mocks/issuermocks/issuermocks.go +++ b/internal/mocks/issuermocks/issuermocks.go @@ -3,7 +3,7 @@ // // Code generated by MockGen. DO NOT EDIT. -// Source: go.pinniped.dev/internal/issuer (interfaces: ClientCertIssuer) +// Source: go.pinniped.dev/internal/clientcertissuer (interfaces: ClientCertIssuer) // Package issuermocks is a generated GoMock package. package issuermocks diff --git a/internal/mocks/mockupstreamoidcidentityprovider/generate.go b/internal/mocks/mockupstreamoidcidentityprovider/generate.go index cb9c46df..e94a5338 100644 --- a/internal/mocks/mockupstreamoidcidentityprovider/generate.go +++ b/internal/mocks/mockupstreamoidcidentityprovider/generate.go @@ -1,6 +1,6 @@ -// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package mockupstreamoidcidentityprovider -//go:generate go run -v github.com/golang/mock/mockgen -destination=mockupstreamoidcidentityprovider.go -package=mockupstreamoidcidentityprovider -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/oidc/provider UpstreamOIDCIdentityProviderI +//go:generate go run -v github.com/golang/mock/mockgen -destination=mockupstreamoidcidentityprovider.go -package=mockupstreamoidcidentityprovider -copyright_file=../../../hack/header.txt go.pinniped.dev/internal/federationdomain/upstreamprovider UpstreamOIDCIdentityProviderI diff --git a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go index 15a709a5..cad36a26 100644 --- a/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go +++ b/internal/mocks/mockupstreamoidcidentityprovider/mockupstreamoidcidentityprovider.go @@ -3,7 +3,7 @@ // // Code generated by MockGen. DO NOT EDIT. -// Source: go.pinniped.dev/internal/oidc/provider (interfaces: UpstreamOIDCIdentityProviderI) +// Source: go.pinniped.dev/internal/federationdomain/upstreamprovider (interfaces: UpstreamOIDCIdentityProviderI) // Package mockupstreamoidcidentityprovider is a generated GoMock package. package mockupstreamoidcidentityprovider @@ -14,7 +14,7 @@ import ( reflect "reflect" gomock "github.com/golang/mock/gomock" - provider "go.pinniped.dev/internal/oidc/provider" + upstreamprovider "go.pinniped.dev/internal/federationdomain/upstreamprovider" nonce "go.pinniped.dev/pkg/oidcclient/nonce" oidctypes "go.pinniped.dev/pkg/oidcclient/oidctypes" pkce "go.pinniped.dev/pkg/oidcclient/pkce" @@ -245,7 +245,7 @@ func (mr *MockUpstreamOIDCIdentityProviderIMockRecorder) PerformRefresh(arg0, ar } // RevokeToken mocks base method. -func (m *MockUpstreamOIDCIdentityProviderI) RevokeToken(arg0 context.Context, arg1 string, arg2 provider.RevocableTokenType) error { +func (m *MockUpstreamOIDCIdentityProviderI) RevokeToken(arg0 context.Context, arg1 string, arg2 upstreamprovider.RevocableTokenType) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RevokeToken", arg0, arg1, arg2) ret0, _ := ret[0].(error) diff --git a/internal/oidc/downstreamsession/downstream_session_test.go b/internal/oidc/downstreamsession/downstream_session_test.go deleted file mode 100644 index d6effd4b..00000000 --- a/internal/oidc/downstreamsession/downstream_session_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2023 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package downstreamsession - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "go.pinniped.dev/internal/testutil/oidctestutil" -) - -func TestMapAdditionalClaimsFromUpstreamIDToken(t *testing.T) { - tests := []struct { - name string - additionalClaimMappings map[string]string - upstreamClaims map[string]interface{} - wantClaims map[string]interface{} - }{ - { - name: "happy path", - additionalClaimMappings: map[string]string{ - "email": "notification_email", - }, - upstreamClaims: map[string]interface{}{ - "notification_email": "test@example.com", - }, - wantClaims: map[string]interface{}{ - "email": "test@example.com", - }, - }, - { - name: "missing", - additionalClaimMappings: map[string]string{ - "email": "email", - }, - upstreamClaims: map[string]interface{}{}, - wantClaims: map[string]interface{}{}, - }, - { - name: "complex", - additionalClaimMappings: map[string]string{ - "complex": "complex", - }, - upstreamClaims: map[string]interface{}{ - "complex": map[string]string{ - "subClaim": "subValue", - }, - }, - wantClaims: map[string]interface{}{ - "complex": map[string]string{ - "subClaim": "subValue", - }, - }, - }, - } - - for _, test := range tests { - test := test - t.Run(test.name, func(t *testing.T) { - t.Parallel() - - idp := oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). - WithAdditionalClaimMappings(test.additionalClaimMappings). - Build() - actual := MapAdditionalClaimsFromUpstreamIDToken(idp, test.upstreamClaims) - - require.Equal(t, test.wantClaims, actual) - }) - } -} diff --git a/internal/oidc/provider/federation_domain_issuer.go b/internal/oidc/provider/federation_domain_issuer.go deleted file mode 100644 index 29e3683c..00000000 --- a/internal/oidc/provider/federation_domain_issuer.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -package provider - -import ( - "fmt" - "net/url" - "strings" - - "go.pinniped.dev/internal/constable" -) - -// FederationDomainIssuer represents all the settings and state for a downstream OIDC provider -// as defined by a FederationDomain. -type FederationDomainIssuer struct { - issuer string - issuerHost string - issuerPath string -} - -// NewFederationDomainIssuer returns a FederationDomainIssuer. -// Performs validation, and returns any error from validation. -func NewFederationDomainIssuer(issuer string) (*FederationDomainIssuer, error) { - p := FederationDomainIssuer{issuer: issuer} - err := p.validate() - if err != nil { - return nil, err - } - return &p, nil -} - -func (p *FederationDomainIssuer) validate() error { - if p.issuer == "" { - return constable.Error("federation domain must have an issuer") - } - - issuerURL, err := url.Parse(p.issuer) - if err != nil { - return fmt.Errorf("could not parse issuer as URL: %w", err) - } - - if issuerURL.Scheme != "https" { - return constable.Error(`issuer must have "https" scheme`) - } - - if issuerURL.Hostname() == "" { - return constable.Error(`issuer must have a hostname`) - } - - if issuerURL.User != nil { - return constable.Error(`issuer must not have username or password`) - } - - if strings.HasSuffix(issuerURL.Path, "/") { - return constable.Error(`issuer must not have trailing slash in path`) - } - - if issuerURL.RawQuery != "" { - return constable.Error(`issuer must not have query`) - } - - if issuerURL.Fragment != "" { - return constable.Error(`issuer must not have fragment`) - } - - p.issuerHost = issuerURL.Host - p.issuerPath = issuerURL.Path - - return nil -} - -// Issuer returns the issuer. -func (p *FederationDomainIssuer) Issuer() string { - return p.issuer -} - -// IssuerHost returns the issuerHost. -func (p *FederationDomainIssuer) IssuerHost() string { - return p.issuerHost -} - -// IssuerPath returns the issuerPath. -func (p *FederationDomainIssuer) IssuerPath() string { - return p.issuerPath -} diff --git a/internal/psession/pinniped_session.go b/internal/psession/pinniped_session.go index 136b2312..bc995ba0 100644 --- a/internal/psession/pinniped_session.go +++ b/internal/psession/pinniped_session.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package psession @@ -32,6 +32,18 @@ type CustomSessionData struct { // all users must have a username. Username string `json:"username"` + // UpstreamUsername is the username from the upstream identity provider during the user's initial login before + // identity transformations were applied. We store this so that we can still reapply identity transformations + // during refresh flows even when an upstream OIDC provider does not return the username again during the upstream + // refresh, and so we can validate that same untransformed username was found during an LDAP refresh. + UpstreamUsername string `json:"upstreamUsername"` + + // UpstreamGroups is the groups list from the upstream identity provider during the user's initial login before + // identity transformations were applied. We store this so that we can still reapply identity transformations + // during refresh flows even when an OIDC provider does not return the groups again during the upstream + // refresh, and when the LDAP search was configured to skip group refreshes. + UpstreamGroups []string `json:"upstreamGroups"` + // The Kubernetes resource UID of the identity provider CRD for the upstream IDP used to start this session. // This should be validated again upon downstream refresh to make sure that we are not refreshing against // a different identity provider CRD which just happens to have the same name. @@ -41,11 +53,12 @@ type CustomSessionData struct { // The Kubernetes resource name of the identity provider CRD for the upstream IDP used to start this session. // Used during a downstream refresh to decide which upstream to refresh. - // Also used to decide which of the pointer types below should be used. + // Also used by the session storage garbage collector to decide which upstream to use for token revocation. ProviderName string `json:"providerName"` // The type of the identity provider for the upstream IDP used to start this session. // Used during a downstream refresh to decide which upstream to refresh. + // Also used to decide which of the pointer types below should be used. ProviderType ProviderType `json:"providerType"` // Warnings that were encountered at some point during login that should be emitted to the client. @@ -55,8 +68,10 @@ type CustomSessionData struct { // Only used when ProviderType == "oidc". OIDC *OIDCSessionData `json:"oidc,omitempty"` + // Only used when ProviderType == "ldap". LDAP *LDAPSessionData `json:"ldap,omitempty"` + // Only used when ProviderType == "activedirectory". ActiveDirectory *ActiveDirectorySessionData `json:"activedirectory,omitempty"` } diff --git a/internal/registry/credentialrequest/rest.go b/internal/registry/credentialrequest/rest.go index 4e272266..4a5631d0 100644 --- a/internal/registry/credentialrequest/rest.go +++ b/internal/registry/credentialrequest/rest.go @@ -21,7 +21,7 @@ import ( "k8s.io/utils/trace" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" - "go.pinniped.dev/internal/issuer" + "go.pinniped.dev/internal/clientcertissuer" ) // clientCertificateTTL is the TTL for short-lived client certificates returned by this API. @@ -31,7 +31,7 @@ type TokenCredentialRequestAuthenticator interface { AuthenticateTokenCredentialRequest(ctx context.Context, req *loginapi.TokenCredentialRequest) (user.Info, error) } -func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer issuer.ClientCertIssuer, resource schema.GroupResource) *REST { +func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer clientcertissuer.ClientCertIssuer, resource schema.GroupResource) *REST { return &REST{ authenticator: authenticator, issuer: issuer, @@ -41,7 +41,7 @@ func NewREST(authenticator TokenCredentialRequestAuthenticator, issuer issuer.Cl type REST struct { authenticator TokenCredentialRequestAuthenticator - issuer issuer.ClientCertIssuer + issuer clientcertissuer.ClientCertIssuer tableConvertor rest.TableConvertor } diff --git a/internal/registry/credentialrequest/rest_test.go b/internal/registry/credentialrequest/rest_test.go index 3ad72844..a9bff18f 100644 --- a/internal/registry/credentialrequest/rest_test.go +++ b/internal/registry/credentialrequest/rest_test.go @@ -25,7 +25,7 @@ import ( "k8s.io/utils/ptr" loginapi "go.pinniped.dev/generated/latest/apis/concierge/login" - "go.pinniped.dev/internal/issuer" + "go.pinniped.dev/internal/clientcertissuer" "go.pinniped.dev/internal/mocks/credentialrequestmocks" "go.pinniped.dev/internal/mocks/issuermocks" "go.pinniped.dev/internal/testutil" @@ -392,7 +392,7 @@ func requireSuccessfulResponseWithAuthenticationFailureMessage(t *testing.T, err }) } -func successfulIssuer(ctrl *gomock.Controller) issuer.ClientCertIssuer { +func successfulIssuer(ctrl *gomock.Controller) clientcertissuer.ClientCertIssuer { clientCertIssuer := issuermocks.NewMockClientCertIssuer(ctrl) clientCertIssuer.EXPECT(). IssueClientCertPEM(gomock.Any(), gomock.Any(), gomock.Any()). diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index bfbb36bf..d6605b2b 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -58,12 +58,13 @@ import ( "go.pinniped.dev/internal/deploymentref" "go.pinniped.dev/internal/downward" "go.pinniped.dev/internal/dynamiccert" + "go.pinniped.dev/internal/federationdomain/dynamictlscertprovider" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/endpoints/jwks" + "go.pinniped.dev/internal/federationdomain/endpointsmanager" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/kubeclient" "go.pinniped.dev/internal/leaderelection" - "go.pinniped.dev/internal/oidc/jwks" - "go.pinniped.dev/internal/oidc/provider" - "go.pinniped.dev/internal/oidc/provider/manager" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/pversion" "go.pinniped.dev/internal/secret" @@ -129,10 +130,10 @@ func signalCtx() context.Context { //nolint:funlen func prepareControllers( cfg *supervisor.Config, - issuerManager *manager.Manager, + issuerManager *endpointsmanager.Manager, dynamicJWKSProvider jwks.DynamicJWKSProvider, - dynamicTLSCertProvider provider.DynamicTLSCertProvider, - dynamicUpstreamIDPProvider provider.DynamicUpstreamIDPProvider, + dynamicTLSCertProvider dynamictlscertprovider.DynamicTLSCertProvider, + dynamicUpstreamIDPProvider dynamicupstreamprovider.DynamicUpstreamIDPProvider, dynamicServingCertProvider dynamiccert.Private, secretCache *secret.Cache, supervisorDeployment *appsv1.Deployment, @@ -166,9 +167,13 @@ func prepareControllers( WithController( supervisorconfig.NewFederationDomainWatcherController( issuerManager, + *cfg.APIGroupSuffix, clock.RealClock{}, pinnipedClient, federationDomainInformer, + pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), + pinnipedInformers.IDP().V1alpha1().LDAPIdentityProviders(), + pinnipedInformers.IDP().V1alpha1().ActiveDirectoryIdentityProviders(), controllerlib.WithInformer, ), singletonWorker, @@ -293,7 +298,7 @@ func prepareControllers( pinnipedClient, pinnipedInformers.IDP().V1alpha1().OIDCIdentityProviders(), secretInformer, - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + plog.Logr(), //nolint:staticcheck // old controller with lots of log statements controllerlib.WithInformer, ), singletonWorker). @@ -433,12 +438,12 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis dynamicServingCertProvider := dynamiccert.NewServingCert("supervisor-serving-cert") dynamicJWKSProvider := jwks.NewDynamicJWKSProvider() - dynamicTLSCertProvider := provider.NewDynamicTLSCertProvider() - dynamicUpstreamIDPProvider := provider.NewDynamicUpstreamIDPProvider() + dynamicTLSCertProvider := dynamictlscertprovider.NewDynamicTLSCertProvider() + dynamicUpstreamIDPProvider := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() secretCache := secret.Cache{} - // OIDC endpoints will be served by the oidProvidersManager, and any non-OIDC paths will fallback to the healthMux. - oidProvidersManager := manager.NewManager( + // OIDC endpoints will be served by the endpoints manager, and any non-OIDC paths will fallback to the healthMux. + oidProvidersManager := endpointsmanager.NewManager( healthMux, dynamicJWKSProvider, dynamicUpstreamIDPProvider, diff --git a/internal/testutil/oidcclient_test.go b/internal/testutil/oidcclient_test.go index cd892313..c47a129a 100644 --- a/internal/testutil/oidcclient_test.go +++ b/internal/testutil/oidcclient_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2022-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testutil @@ -6,10 +6,10 @@ package testutil import ( "testing" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" - "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" + + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" ) func TestBcryptConstants(t *testing.T) { diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index 474e5cbc..a3c55591 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -30,16 +30,19 @@ import ( "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/crud" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/resolvedprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/fositestorage/authorizationcode" "go.pinniped.dev/internal/fositestorage/openidconnect" - pkce2 "go.pinniped.dev/internal/fositestorage/pkce" + "go.pinniped.dev/internal/fositestorage/pkce" "go.pinniped.dev/internal/fositestoragei" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/idtransform" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" - "go.pinniped.dev/pkg/oidcclient/pkce" + oidcpkce "go.pinniped.dev/pkg/oidcclient/pkce" ) // Test helpers for the OIDC package. @@ -49,7 +52,7 @@ import ( type ExchangeAuthcodeAndValidateTokenArgs struct { Ctx context.Context Authcode string - PKCECodeVerifier pkce.Code + PKCECodeVerifier oidcpkce.Code ExpectedIDTokenNonce nonce.Nonce RedirectURI string } @@ -77,7 +80,7 @@ type PerformRefreshArgs struct { type RevokeTokenArgs struct { Ctx context.Context Token string - TokenType provider.RevocableTokenType + TokenType upstreamprovider.RevocableTokenType } // ValidateTokenAndMergeWithUserInfoArgs is used to spy on calls to @@ -93,21 +96,113 @@ type ValidateTokenAndMergeWithUserInfoArgs struct { type ValidateRefreshArgs struct { Ctx context.Context Tok *oauth2.Token - StoredAttributes provider.RefreshAttributes + StoredAttributes upstreamprovider.RefreshAttributes +} + +func NewTestUpstreamLDAPIdentityProviderBuilder() *TestUpstreamLDAPIdentityProviderBuilder { + return &TestUpstreamLDAPIdentityProviderBuilder{} +} + +type TestUpstreamLDAPIdentityProviderBuilder struct { + name string + resourceUID types.UID + url *url.URL + authenticateFunc func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) + performRefreshCallCount int + performRefreshArgs []*PerformRefreshArgs + performRefreshErr error + performRefreshGroups []string + displayNameForFederationDomain string + transformsForFederationDomain *idtransform.TransformationPipeline +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithName(name string) *TestUpstreamLDAPIdentityProviderBuilder { + t.name = name + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithResourceUID(uid types.UID) *TestUpstreamLDAPIdentityProviderBuilder { + t.resourceUID = uid + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithURL(url *url.URL) *TestUpstreamLDAPIdentityProviderBuilder { + t.url = url + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithAuthenticateFunc(f func(ctx context.Context, username, password string) (*authenticators.Response, bool, error)) *TestUpstreamLDAPIdentityProviderBuilder { + t.authenticateFunc = f + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithPerformRefreshCallCount(count int) *TestUpstreamLDAPIdentityProviderBuilder { + t.performRefreshCallCount = count + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithPerformRefreshArgs(args []*PerformRefreshArgs) *TestUpstreamLDAPIdentityProviderBuilder { + t.performRefreshArgs = args + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithPerformRefreshErr(err error) *TestUpstreamLDAPIdentityProviderBuilder { + t.performRefreshErr = err + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithPerformRefreshGroups(groups []string) *TestUpstreamLDAPIdentityProviderBuilder { + t.performRefreshGroups = groups + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithDisplayNameForFederationDomain(displayName string) *TestUpstreamLDAPIdentityProviderBuilder { + t.displayNameForFederationDomain = displayName + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) WithTransformsForFederationDomain(transforms *idtransform.TransformationPipeline) *TestUpstreamLDAPIdentityProviderBuilder { + t.transformsForFederationDomain = transforms + return t +} + +func (t *TestUpstreamLDAPIdentityProviderBuilder) Build() *TestUpstreamLDAPIdentityProvider { + if t.displayNameForFederationDomain == "" { + // default it to the CR name + t.displayNameForFederationDomain = t.name + } + if t.transformsForFederationDomain == nil { + // default to an empty pipeline + t.transformsForFederationDomain = idtransform.NewTransformationPipeline() + } + return &TestUpstreamLDAPIdentityProvider{ + Name: t.name, + ResourceUID: t.resourceUID, + URL: t.url, + AuthenticateFunc: t.authenticateFunc, + performRefreshCallCount: t.performRefreshCallCount, + performRefreshArgs: t.performRefreshArgs, + PerformRefreshErr: t.performRefreshErr, + PerformRefreshGroups: t.performRefreshGroups, + DisplayNameForFederationDomain: t.displayNameForFederationDomain, + TransformsForFederationDomain: t.transformsForFederationDomain, + } } type TestUpstreamLDAPIdentityProvider struct { - Name string - ResourceUID types.UID - URL *url.URL - AuthenticateFunc func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) - performRefreshCallCount int - performRefreshArgs []*PerformRefreshArgs - PerformRefreshErr error - PerformRefreshGroups []string + Name string + ResourceUID types.UID + URL *url.URL + AuthenticateFunc func(ctx context.Context, username, password string) (*authenticators.Response, bool, error) + performRefreshCallCount int + performRefreshArgs []*PerformRefreshArgs + PerformRefreshErr error + PerformRefreshGroups []string + DisplayNameForFederationDomain string + TransformsForFederationDomain *idtransform.TransformationPipeline } -var _ provider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{} +var _ upstreamprovider.UpstreamLDAPIdentityProviderI = &TestUpstreamLDAPIdentityProvider{} func (u *TestUpstreamLDAPIdentityProvider) GetResourceUID() types.UID { return u.ResourceUID @@ -125,7 +220,7 @@ func (u *TestUpstreamLDAPIdentityProvider) GetURL() *url.URL { return u.URL } -func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.RefreshAttributes) ([]string, error) { +func (u *TestUpstreamLDAPIdentityProvider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, idpDisplayName string) ([]string, error) { if u.performRefreshArgs == nil { u.performRefreshArgs = make([]*PerformRefreshArgs, 0) } @@ -154,23 +249,25 @@ func (u *TestUpstreamLDAPIdentityProvider) PerformRefreshArgs(call int) *Perform } type TestUpstreamOIDCIdentityProvider struct { - Name string - ClientID string - ResourceUID types.UID - AuthorizationURL url.URL - UserInfoURL bool - RevocationURL *url.URL - UsernameClaim string - GroupsClaim string - Scopes []string - AdditionalAuthcodeParams map[string]string - AdditionalClaimMappings map[string]string - AllowPasswordGrant bool + Name string + ClientID string + ResourceUID types.UID + AuthorizationURL url.URL + UserInfoURL bool + RevocationURL *url.URL + UsernameClaim string + GroupsClaim string + Scopes []string + AdditionalAuthcodeParams map[string]string + AdditionalClaimMappings map[string]string + AllowPasswordGrant bool + DisplayNameForFederationDomain string + TransformsForFederationDomain *idtransform.TransformationPipeline ExchangeAuthcodeAndValidateTokensFunc func( ctx context.Context, authcode string, - pkceCodeVerifier pkce.Code, + pkceCodeVerifier oidcpkce.Code, expectedIDTokenNonce nonce.Nonce, ) (*oidctypes.Token, error) @@ -182,7 +279,7 @@ type TestUpstreamOIDCIdentityProvider struct { PerformRefreshFunc func(ctx context.Context, refreshToken string) (*oauth2.Token, error) - RevokeTokenFunc func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error + RevokeTokenFunc func(ctx context.Context, refreshToken string, tokenType upstreamprovider.RevocableTokenType) error ValidateTokenAndMergeWithUserInfoFunc func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) @@ -198,7 +295,7 @@ type TestUpstreamOIDCIdentityProvider struct { validateTokenAndMergeWithUserInfoArgs []*ValidateTokenAndMergeWithUserInfoArgs } -var _ provider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{} +var _ upstreamprovider.UpstreamOIDCIdentityProviderI = &TestUpstreamOIDCIdentityProvider{} func (u *TestUpstreamOIDCIdentityProvider) GetResourceUID() types.UID { return u.ResourceUID @@ -261,7 +358,7 @@ func (u *TestUpstreamOIDCIdentityProvider) PasswordCredentialsGrantAndValidateTo func (u *TestUpstreamOIDCIdentityProvider) ExchangeAuthcodeAndValidateTokens( ctx context.Context, authcode string, - pkceCodeVerifier pkce.Code, + pkceCodeVerifier oidcpkce.Code, expectedIDTokenNonce nonce.Nonce, redirectURI string, ) (*oidctypes.Token, error) { @@ -302,7 +399,7 @@ func (u *TestUpstreamOIDCIdentityProvider) PerformRefresh(ctx context.Context, r return u.PerformRefreshFunc(ctx, refreshToken) } -func (u *TestUpstreamOIDCIdentityProvider) RevokeToken(ctx context.Context, token string, tokenType provider.RevocableTokenType) error { +func (u *TestUpstreamOIDCIdentityProvider) RevokeToken(ctx context.Context, token string, tokenType upstreamprovider.RevocableTokenType) error { if u.revokeTokenArgs == nil { u.revokeTokenArgs = make([]*RevokeTokenArgs, 0) } @@ -363,10 +460,113 @@ func (u *TestUpstreamOIDCIdentityProvider) ValidateTokenAndMergeWithUserInfoArgs return u.validateTokenAndMergeWithUserInfoArgs[call] } +type TestFederationDomainIdentityProvidersListerFinder struct { + upstreamOIDCIdentityProviders []*TestUpstreamOIDCIdentityProvider + upstreamLDAPIdentityProviders []*TestUpstreamLDAPIdentityProvider + upstreamActiveDirectoryIdentityProviders []*TestUpstreamLDAPIdentityProvider + defaultIDPDisplayName string +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) GetOIDCIdentityProviders() []*resolvedprovider.FederationDomainResolvedOIDCIdentityProvider { + fdIDPs := make([]*resolvedprovider.FederationDomainResolvedOIDCIdentityProvider, len(t.upstreamOIDCIdentityProviders)) + for i, testIDP := range t.upstreamOIDCIdentityProviders { + fdIDP := &resolvedprovider.FederationDomainResolvedOIDCIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeOIDC, + Transforms: testIDP.TransformsForFederationDomain, + } + fdIDPs[i] = fdIDP + } + return fdIDPs +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) GetLDAPIdentityProviders() []*resolvedprovider.FederationDomainResolvedLDAPIdentityProvider { + fdIDPs := make([]*resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, len(t.upstreamLDAPIdentityProviders)) + for i, testIDP := range t.upstreamLDAPIdentityProviders { + fdIDP := &resolvedprovider.FederationDomainResolvedLDAPIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeLDAP, + Transforms: testIDP.TransformsForFederationDomain, + } + fdIDPs[i] = fdIDP + } + return fdIDPs +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) GetActiveDirectoryIdentityProviders() []*resolvedprovider.FederationDomainResolvedLDAPIdentityProvider { + fdIDPs := make([]*resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, len(t.upstreamActiveDirectoryIdentityProviders)) + for i, testIDP := range t.upstreamActiveDirectoryIdentityProviders { + fdIDP := &resolvedprovider.FederationDomainResolvedLDAPIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeActiveDirectory, + Transforms: testIDP.TransformsForFederationDomain, + } + fdIDPs[i] = fdIDP + } + return fdIDPs +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) FindDefaultIDP() (*resolvedprovider.FederationDomainResolvedOIDCIdentityProvider, *resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, error) { + if t.defaultIDPDisplayName == "" { + return nil, nil, fmt.Errorf("identity provider not found: this federation domain does not have a default identity provider") + } + return t.FindUpstreamIDPByDisplayName(t.defaultIDPDisplayName) +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) FindUpstreamIDPByDisplayName(upstreamIDPDisplayName string) (*resolvedprovider.FederationDomainResolvedOIDCIdentityProvider, *resolvedprovider.FederationDomainResolvedLDAPIdentityProvider, error) { + for _, testIDP := range t.upstreamOIDCIdentityProviders { + if upstreamIDPDisplayName == testIDP.DisplayNameForFederationDomain { + return &resolvedprovider.FederationDomainResolvedOIDCIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeOIDC, + Transforms: testIDP.TransformsForFederationDomain, + }, nil, nil + } + } + for _, testIDP := range t.upstreamLDAPIdentityProviders { + if upstreamIDPDisplayName == testIDP.DisplayNameForFederationDomain { + return nil, &resolvedprovider.FederationDomainResolvedLDAPIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeLDAP, + Transforms: testIDP.TransformsForFederationDomain, + }, nil + } + } + for _, testIDP := range t.upstreamActiveDirectoryIdentityProviders { + if upstreamIDPDisplayName == testIDP.DisplayNameForFederationDomain { + return nil, &resolvedprovider.FederationDomainResolvedLDAPIdentityProvider{ + DisplayName: testIDP.DisplayNameForFederationDomain, + Provider: testIDP, + SessionProviderType: psession.ProviderTypeActiveDirectory, + Transforms: testIDP.TransformsForFederationDomain, + }, nil + } + } + return nil, nil, fmt.Errorf("did not find IDP with name %q", upstreamIDPDisplayName) +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) SetOIDCIdentityProviders(providers []*TestUpstreamOIDCIdentityProvider) { + t.upstreamOIDCIdentityProviders = providers +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) SetLDAPIdentityProviders(providers []*TestUpstreamLDAPIdentityProvider) { + t.upstreamLDAPIdentityProviders = providers +} + +func (t *TestFederationDomainIdentityProvidersListerFinder) SetActiveDirectoryIdentityProviders(providers []*TestUpstreamLDAPIdentityProvider) { + t.upstreamActiveDirectoryIdentityProviders = providers +} + type UpstreamIDPListerBuilder struct { upstreamOIDCIdentityProviders []*TestUpstreamOIDCIdentityProvider upstreamLDAPIdentityProviders []*TestUpstreamLDAPIdentityProvider upstreamActiveDirectoryIdentityProviders []*TestUpstreamLDAPIdentityProvider + defaultIDPDisplayName string } func (b *UpstreamIDPListerBuilder) WithOIDC(upstreamOIDCIdentityProviders ...*TestUpstreamOIDCIdentityProvider) *UpstreamIDPListerBuilder { @@ -384,24 +584,38 @@ func (b *UpstreamIDPListerBuilder) WithActiveDirectory(upstreamActiveDirectoryId return b } -func (b *UpstreamIDPListerBuilder) Build() provider.DynamicUpstreamIDPProvider { - idpProvider := provider.NewDynamicUpstreamIDPProvider() +func (b *UpstreamIDPListerBuilder) WithDefaultIDPDisplayName(defaultIDPDisplayName string) *UpstreamIDPListerBuilder { + b.defaultIDPDisplayName = defaultIDPDisplayName + return b +} - oidcUpstreams := make([]provider.UpstreamOIDCIdentityProviderI, len(b.upstreamOIDCIdentityProviders)) +func (b *UpstreamIDPListerBuilder) BuildFederationDomainIdentityProvidersListerFinder() *TestFederationDomainIdentityProvidersListerFinder { + return &TestFederationDomainIdentityProvidersListerFinder{ + upstreamOIDCIdentityProviders: b.upstreamOIDCIdentityProviders, + upstreamLDAPIdentityProviders: b.upstreamLDAPIdentityProviders, + upstreamActiveDirectoryIdentityProviders: b.upstreamActiveDirectoryIdentityProviders, + defaultIDPDisplayName: b.defaultIDPDisplayName, + } +} + +func (b *UpstreamIDPListerBuilder) BuildDynamicUpstreamIDPProvider() dynamicupstreamprovider.DynamicUpstreamIDPProvider { + idpProvider := dynamicupstreamprovider.NewDynamicUpstreamIDPProvider() + + oidcUpstreams := make([]upstreamprovider.UpstreamOIDCIdentityProviderI, len(b.upstreamOIDCIdentityProviders)) for i := range b.upstreamOIDCIdentityProviders { - oidcUpstreams[i] = provider.UpstreamOIDCIdentityProviderI(b.upstreamOIDCIdentityProviders[i]) + oidcUpstreams[i] = upstreamprovider.UpstreamOIDCIdentityProviderI(b.upstreamOIDCIdentityProviders[i]) } idpProvider.SetOIDCIdentityProviders(oidcUpstreams) - ldapUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, len(b.upstreamLDAPIdentityProviders)) + ldapUpstreams := make([]upstreamprovider.UpstreamLDAPIdentityProviderI, len(b.upstreamLDAPIdentityProviders)) for i := range b.upstreamLDAPIdentityProviders { - ldapUpstreams[i] = provider.UpstreamLDAPIdentityProviderI(b.upstreamLDAPIdentityProviders[i]) + ldapUpstreams[i] = upstreamprovider.UpstreamLDAPIdentityProviderI(b.upstreamLDAPIdentityProviders[i]) } idpProvider.SetLDAPIdentityProviders(ldapUpstreams) - adUpstreams := make([]provider.UpstreamLDAPIdentityProviderI, len(b.upstreamActiveDirectoryIdentityProviders)) + adUpstreams := make([]upstreamprovider.UpstreamLDAPIdentityProviderI, len(b.upstreamActiveDirectoryIdentityProviders)) for i := range b.upstreamActiveDirectoryIdentityProviders { - adUpstreams[i] = provider.UpstreamLDAPIdentityProviderI(b.upstreamActiveDirectoryIdentityProviders[i]) + adUpstreams[i] = upstreamprovider.UpstreamLDAPIdentityProviderI(b.upstreamActiveDirectoryIdentityProviders[i]) } idpProvider.SetActiveDirectoryIdentityProviders(adUpstreams) @@ -642,6 +856,8 @@ type TestUpstreamOIDCIdentityProviderBuilder struct { performRefreshErr error revokeTokenErr error validateTokenAndMergeWithUserInfoErr error + displayNameForFederationDomain string + transformsForFederationDomain *idtransform.TransformationPipeline } func (u *TestUpstreamOIDCIdentityProviderBuilder) WithName(value string) *TestUpstreamOIDCIdentityProviderBuilder { @@ -791,20 +1007,41 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) WithRevokeTokenError(err error return u } +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithDisplayNameForFederationDomain(displayName string) *TestUpstreamOIDCIdentityProviderBuilder { + u.displayNameForFederationDomain = displayName + return u +} + +func (u *TestUpstreamOIDCIdentityProviderBuilder) WithTransformsForFederationDomain(transforms *idtransform.TransformationPipeline) *TestUpstreamOIDCIdentityProviderBuilder { + u.transformsForFederationDomain = transforms + return u +} + func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdentityProvider { + if u.displayNameForFederationDomain == "" { + // default it to the CR name + u.displayNameForFederationDomain = u.name + } + if u.transformsForFederationDomain == nil { + // default to an empty pipeline + u.transformsForFederationDomain = idtransform.NewTransformationPipeline() + } + return &TestUpstreamOIDCIdentityProvider{ - Name: u.name, - ClientID: u.clientID, - ResourceUID: u.resourceUID, - UsernameClaim: u.usernameClaim, - GroupsClaim: u.groupsClaim, - Scopes: u.scopes, - AllowPasswordGrant: u.allowPasswordGrant, - AuthorizationURL: u.authorizationURL, - UserInfoURL: u.hasUserInfoURL, - AdditionalAuthcodeParams: u.additionalAuthcodeParams, - AdditionalClaimMappings: u.additionalClaimMappings, - ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier pkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { + Name: u.name, + ClientID: u.clientID, + ResourceUID: u.resourceUID, + UsernameClaim: u.usernameClaim, + GroupsClaim: u.groupsClaim, + Scopes: u.scopes, + AllowPasswordGrant: u.allowPasswordGrant, + AuthorizationURL: u.authorizationURL, + UserInfoURL: u.hasUserInfoURL, + AdditionalAuthcodeParams: u.additionalAuthcodeParams, + AdditionalClaimMappings: u.additionalClaimMappings, + DisplayNameForFederationDomain: u.displayNameForFederationDomain, + TransformsForFederationDomain: u.transformsForFederationDomain, + ExchangeAuthcodeAndValidateTokensFunc: func(ctx context.Context, authcode string, pkceCodeVerifier oidcpkce.Code, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { if u.authcodeExchangeErr != nil { return nil, u.authcodeExchangeErr } @@ -822,7 +1059,7 @@ func (u *TestUpstreamOIDCIdentityProviderBuilder) Build() *TestUpstreamOIDCIdent } return u.refreshedTokens, nil }, - RevokeTokenFunc: func(ctx context.Context, refreshToken string, tokenType provider.RevocableTokenType) error { + RevokeTokenFunc: func(ctx context.Context, refreshToken string, tokenType upstreamprovider.RevocableTokenType) error { return u.revokeTokenErr }, ValidateTokenAndMergeWithUserInfoFunc: func(ctx context.Context, tok *oauth2.Token, expectedIDTokenNonce nonce.Nonce) (*oidctypes.Token, error) { @@ -989,7 +1226,7 @@ func RequireAuthCodeRegexpMatch( ) // One PKCE should have been stored. - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: pkce2.TypeLabelValue}, 1) + testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: pkce.TypeLabelValue}, 1) validatePKCEStorage( t, diff --git a/internal/testutil/psession.go b/internal/testutil/psession.go index 88eb658b..8efbcf25 100644 --- a/internal/testutil/psession.go +++ b/internal/testutil/psession.go @@ -1,4 +1,4 @@ -// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testutil @@ -24,10 +24,12 @@ func NewFakePinnipedSession() *psession.PinnipedSession { Subject: "panda", }, Custom: &psession.CustomSessionData{ - Username: "fake-username", - ProviderUID: "fake-provider-uid", - ProviderType: "fake-provider-type", - ProviderName: "fake-provider-name", + Username: "fake-username", + ProviderUID: "fake-provider-uid", + ProviderType: "fake-provider-type", + ProviderName: "fake-provider-name", + UpstreamUsername: "fake-upstream-username", + UpstreamGroups: []string{"fake-upstream-group1", "fake-upstream-group2"}, OIDC: &psession.OIDCSessionData{ UpstreamRefreshToken: "fake-upstream-refresh-token", UpstreamSubject: "some-subject", diff --git a/internal/testutil/string_slice.go b/internal/testutil/string_slice.go new file mode 100644 index 00000000..d629698f --- /dev/null +++ b/internal/testutil/string_slice.go @@ -0,0 +1,14 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import "fmt" + +func AddPrefixToEach(prefix string, addToEach []string) []string { + result := make([]string, len(addToEach)) + for i, s := range addToEach { + result[i] = fmt.Sprintf("%s%s", prefix, s) + } + return result +} diff --git a/internal/testutil/transformtestutil/transformtestutil.go b/internal/testutil/transformtestutil/transformtestutil.go new file mode 100644 index 00000000..63576a18 --- /dev/null +++ b/internal/testutil/transformtestutil/transformtestutil.go @@ -0,0 +1,58 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package transformtestutil + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "go.pinniped.dev/internal/celtransformer" + "go.pinniped.dev/internal/idtransform" +) + +func NewPrefixingPipeline(t *testing.T, usernamePrefix, groupsPrefix string) *idtransform.TransformationPipeline { + t.Helper() + + transformer, err := celtransformer.NewCELTransformer(5 * time.Second) + require.NoError(t, err) + + p := idtransform.NewTransformationPipeline() + + userTransform, err := transformer.CompileTransformation( + &celtransformer.UsernameTransformation{Expression: fmt.Sprintf(`"%s" + username`, usernamePrefix)}, + nil, + ) + require.NoError(t, err) + p.AppendTransformation(userTransform) + + groupsTransform, err := transformer.CompileTransformation( + &celtransformer.GroupsTransformation{Expression: fmt.Sprintf(`groups.map(g, "%s" + g)`, groupsPrefix)}, + nil, + ) + require.NoError(t, err) + p.AppendTransformation(groupsTransform) + + return p +} + +func NewRejectAllAuthPipeline(t *testing.T) *idtransform.TransformationPipeline { + t.Helper() + + transformer, err := celtransformer.NewCELTransformer(5 * time.Second) + require.NoError(t, err) + + p := idtransform.NewTransformationPipeline() + + compiledTransform, err := transformer.CompileTransformation( + &celtransformer.AllowAuthenticationPolicy{Expression: `false`}, + nil, + ) + require.NoError(t, err) + p.AppendTransformation(compiledTransform) + + return p +} diff --git a/internal/upstreamldap/upstreamldap.go b/internal/upstreamldap/upstreamldap.go index beb6a037..ef1c432a 100644 --- a/internal/upstreamldap/upstreamldap.go +++ b/internal/upstreamldap/upstreamldap.go @@ -27,8 +27,8 @@ import ( "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/endpointaddr" - "go.pinniped.dev/internal/oidc/downstreamsession" - "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/federationdomain/downstreamsession" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/plog" ) @@ -120,7 +120,7 @@ type ProviderConfig struct { GroupAttributeParsingOverrides map[string]func(*ldap.Entry) (string, error) // RefreshAttributeChecks are extra checks that attributes in a refresh response are as expected. - RefreshAttributeChecks map[string]func(*ldap.Entry, provider.RefreshAttributes) error + RefreshAttributeChecks map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error } // UserSearchConfig contains information about how to search for users in the upstream LDAP IDP. @@ -167,7 +167,7 @@ type Provider struct { c ProviderConfig } -var _ provider.UpstreamLDAPIdentityProviderI = &Provider{} +var _ upstreamprovider.UpstreamLDAPIdentityProviderI = &Provider{} var _ authenticators.UserAuthenticator = &Provider{} // New creates a Provider. The config is not a pointer to ensure that a copy of the config is created, @@ -188,7 +188,7 @@ func closeAndLogError(conn Conn, doingWhat string) { } } -func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes provider.RefreshAttributes) ([]string, error) { +func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes upstreamprovider.RefreshAttributes, idpDisplayName string) ([]string, error) { t := trace.FromContext(ctx).Nest("slow ldap refresh attempt", trace.Field{Key: "providerName", Value: p.GetName()}) defer t.LogIfLong(500 * time.Millisecond) // to help users debug slow LDAP searches userDN := storedRefreshAttributes.DN @@ -237,7 +237,7 @@ func (p *Provider) PerformRefresh(ctx context.Context, storedRefreshAttributes p if err != nil { return nil, err } - newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL()) + newSubject := downstreamsession.DownstreamLDAPSubject(newUID, *p.GetURL(), idpDisplayName) if newSubject != storedRefreshAttributes.Subject { return nil, fmt.Errorf(`searching for user %q produced a different subject than the previous value. expected: %q, actual: %q`, userDN, storedRefreshAttributes.Subject, newSubject) } diff --git a/internal/upstreamldap/upstreamldap_test.go b/internal/upstreamldap/upstreamldap_test.go index 7de3f828..574b478d 100644 --- a/internal/upstreamldap/upstreamldap_test.go +++ b/internal/upstreamldap/upstreamldap_test.go @@ -25,8 +25,8 @@ import ( "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/crypto/ptls" "go.pinniped.dev/internal/endpointaddr" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/mocks/mockldapconn" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/tlsassertions" "go.pinniped.dev/internal/testutil/tlsserver" @@ -36,6 +36,7 @@ const ( testHost = "ldap.example.com:8443" testBindUsername = "cn=some-bind-username,dc=pinniped,dc=dev" testBindPassword = "some-bind-password" + testUpstreamName = "some-upstream-idp-name" testUpstreamUsername = "some-upstream-username" testUpstreamPassword = "some-upstream-password" testUserSearchBase = "some-upstream-user-base-dn" @@ -661,8 +662,8 @@ func TestEndUserAuthentication(t *testing.T) { username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.RefreshAttributes) error{ - "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error { return nil }, } @@ -699,8 +700,8 @@ func TestEndUserAuthentication(t *testing.T) { username: testUpstreamUsername, password: testUpstreamPassword, providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes provider.RefreshAttributes) error{ - "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes provider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error{ + "some-attribute-to-check-during-refresh": func(entry *ldap.Entry, attributes upstreamprovider.RefreshAttributes) error { return nil }, } @@ -1575,8 +1576,8 @@ func TestUpstreamRefresh(t *testing.T) { Filter: testGroupSearchFilter, GroupNameAttribute: testGroupSearchGroupNameAttribute, }, - RefreshAttributeChecks: map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ - pwdLastSetAttribute: func(*ldap.Entry, provider.RefreshAttributes) error { return nil }, + RefreshAttributeChecks: map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { return nil }, }, } if editFunc != nil { @@ -2046,7 +2047,7 @@ func TestUpstreamRefresh(t *testing.T) { }, nil).Times(1) conn.EXPECT().Close().Times(1) }, - wantErr: "searching for user \"some-upstream-user-dn\" produced a different subject than the previous value. expected: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU\", actual: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=d3JvbmctdWlk\"", + wantErr: "searching for user \"some-upstream-user-dn\" produced a different subject than the previous value. expected: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&idpName=some-upstream-idp-name&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU\", actual: \"ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&idpName=some-upstream-idp-name&sub=d3JvbmctdWlk\"", }, { name: "search result has wrong username", @@ -2202,8 +2203,8 @@ func TestUpstreamRefresh(t *testing.T) { { name: "search result has a changed pwdLastSet value", providerConfig: providerConfig(func(p *ProviderConfig) { - p.RefreshAttributeChecks = map[string]func(*ldap.Entry, provider.RefreshAttributes) error{ - pwdLastSetAttribute: func(*ldap.Entry, provider.RefreshAttributes) error { + p.RefreshAttributeChecks = map[string]func(*ldap.Entry, upstreamprovider.RefreshAttributes) error{ + pwdLastSetAttribute: func(*ldap.Entry, upstreamprovider.RefreshAttributes) error { return errors.New(`value for attribute "pwdLastSet" has changed since initial value at login`) }, } @@ -2279,14 +2280,17 @@ func TestUpstreamRefresh(t *testing.T) { } initialPwdLastSetEncoded := base64.RawURLEncoding.EncodeToString([]byte("132801740800000000")) ldapProvider := New(*tt.providerConfig) - subject := "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU" - groups, err := ldapProvider.PerformRefresh(context.Background(), provider.RefreshAttributes{ + subject := fmt.Sprintf( + "ldaps://ldap.example.com:8443?base=some-upstream-user-base-dn&idpName=%s&sub=c29tZS11cHN0cmVhbS11aWQtdmFsdWU", + testUpstreamName, + ) + groups, err := ldapProvider.PerformRefresh(context.Background(), upstreamprovider.RefreshAttributes{ Username: testUserSearchResultUsernameAttributeValue, Subject: subject, DN: tt.refreshUserDN, AdditionalAttributes: map[string]string{pwdLastSetAttribute: initialPwdLastSetEncoded}, GrantedScopes: tt.grantedScopes, - }) + }, testUpstreamName) if tt.wantErr != "" { require.Error(t, err) require.Equal(t, tt.wantErr, err.Error()) diff --git a/internal/upstreamoidc/upstreamoidc.go b/internal/upstreamoidc/upstreamoidc.go index ff8b83fc..8cd569a1 100644 --- a/internal/upstreamoidc/upstreamoidc.go +++ b/internal/upstreamoidc/upstreamoidc.go @@ -21,15 +21,16 @@ import ( "k8s.io/apimachinery/pkg/util/sets" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" "go.pinniped.dev/pkg/oidcclient/pkce" ) -func New(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { +func New(config *oauth2.Config, provider *coreosoidc.Provider, client *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { return &ProviderConfig{Config: config, Provider: provider, Client: client} } @@ -52,7 +53,7 @@ type ProviderConfig struct { } } -var _ provider.UpstreamOIDCIdentityProviderI = (*ProviderConfig)(nil) +var _ upstreamprovider.UpstreamOIDCIdentityProviderI = (*ProviderConfig)(nil) func (p *ProviderConfig) GetResourceUID() types.UID { return p.ResourceUID @@ -160,7 +161,7 @@ func (p *ProviderConfig) PerformRefresh(ctx context.Context, refreshToken string // It may return an error wrapped by a RetryableRevocationError, which is an error indicating that it may // be worth trying to revoke the same token again later. Any other error returned should be assumed to // represent an error such that it is not worth retrying revocation later, even though revocation failed. -func (p *ProviderConfig) RevokeToken(ctx context.Context, token string, tokenType provider.RevocableTokenType) error { +func (p *ProviderConfig) RevokeToken(ctx context.Context, token string, tokenType upstreamprovider.RevocableTokenType) error { if p.RevocationURL == nil { plog.Trace("RevokeToken() was called but upstream provider has no available revocation endpoint", "providerName", p.Name, @@ -188,7 +189,7 @@ func (p *ProviderConfig) RevokeToken(ctx context.Context, token string, tokenTyp func (p *ProviderConfig) tryRevokeToken( ctx context.Context, token string, - tokenType provider.RevocableTokenType, + tokenType upstreamprovider.RevocableTokenType, useBasicAuth bool, ) (tryAnotherClientAuthMethod bool, err error) { clientID := p.Config.ClientID @@ -220,7 +221,7 @@ func (p *ProviderConfig) tryRevokeToken( if err != nil { // Couldn't connect to the server or some similar error. // Could be a temporary network problem, so it might be worth retrying. - return false, provider.NewRetryableRevocationError(err) + return false, dynamicupstreamprovider.NewRetryableRevocationError(err) } defer resp.Body.Close() @@ -270,7 +271,7 @@ func (p *ProviderConfig) tryRevokeToken( // be caused by an underlying problem which could potentially become resolved in the near future. We'll be // optimistic and call all 5xx errors retryable. plog.Trace("RevokeToken() got unexpected error response from provider's revocation endpoint", "providerName", p.Name, "usedBasicAuth", useBasicAuth, "statusCode", status) - return false, provider.NewRetryableRevocationError(fmt.Errorf("server responded with status %d", status)) + return false, dynamicupstreamprovider.NewRetryableRevocationError(fmt.Errorf("server responded with status %d", status)) default: // Any other error is probably not due to failed client auth, and is probably not worth retrying later. plog.Trace("RevokeToken() got unexpected error response from provider's revocation endpoint", "providerName", p.Name, "usedBasicAuth", useBasicAuth, "statusCode", status) diff --git a/internal/upstreamoidc/upstreamoidc_test.go b/internal/upstreamoidc/upstreamoidc_test.go index 1155deb3..cc4f06a2 100644 --- a/internal/upstreamoidc/upstreamoidc_test.go +++ b/internal/upstreamoidc/upstreamoidc_test.go @@ -23,8 +23,9 @@ import ( "gopkg.in/square/go-jose.v2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "go.pinniped.dev/internal/federationdomain/dynamicupstreamprovider" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/mocks/mockkeyset" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -484,7 +485,7 @@ func TestProviderConfig(t *testing.T) { t.Run("RevokeToken", func(t *testing.T) { tests := []struct { name string - tokenType provider.RevocableTokenType + tokenType upstreamprovider.RevocableTokenType nilRevocationURL bool unreachableServer bool returnStatusCodes []int @@ -496,33 +497,33 @@ func TestProviderConfig(t *testing.T) { }{ { name: "success without calling the server when there is no revocation URL set for refresh token", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, nilRevocationURL: true, wantNumRequests: 0, }, { name: "success without calling the server when there is no revocation URL set for access token", - tokenType: provider.AccessTokenType, + tokenType: upstreamprovider.AccessTokenType, nilRevocationURL: true, wantNumRequests: 0, }, { name: "success when the server returns 200 OK on the first call for refresh token", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusOK}, wantNumRequests: 1, wantTokenTypeHint: "refresh_token", }, { name: "success when the server returns 200 OK on the first call for access token", - tokenType: provider.AccessTokenType, + tokenType: upstreamprovider.AccessTokenType, returnStatusCodes: []int{http.StatusOK}, wantNumRequests: 1, wantTokenTypeHint: "access_token", }, { name: "success when the server returns 400 Bad Request on the first call due to client auth, then 200 OK on second call for refresh token", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest, http.StatusOK}, // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 defines this as the error for client auth failure returnErrBodies: []string{`{ "error":"invalid_client", "error_description":"unhappy" }`}, @@ -531,7 +532,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "success when the server returns 400 Bad Request on the first call due to client auth, then 200 OK on second call for access token", - tokenType: provider.AccessTokenType, + tokenType: upstreamprovider.AccessTokenType, returnStatusCodes: []int{http.StatusBadRequest, http.StatusOK}, // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 defines this as the error for client auth failure returnErrBodies: []string{`{ "error":"invalid_client", "error_description":"unhappy" }`}, @@ -540,7 +541,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when the server returns 400 Bad Request on the first call due to client auth, then any 400 error on second call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest, http.StatusBadRequest}, returnErrBodies: []string{`{ "error":"invalid_client", "error_description":"unhappy" }`, `{ "error":"anything", "error_description":"unhappy" }`}, wantErr: testutil.WantExactErrorString(`server responded with status 400 with body: { "error":"anything", "error_description":"unhappy" }`), @@ -550,7 +551,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when the server returns 400 Bad Request with bad JSON body on the first call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest}, returnErrBodies: []string{`invalid JSON body`}, wantErr: testutil.WantExactErrorString(`error parsing response body "invalid JSON body" on response with status code 400: invalid character 'i' looking for beginning of value`), @@ -560,7 +561,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when the server returns 400 Bad Request with empty body", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest}, returnErrBodies: []string{``}, wantErr: testutil.WantExactErrorString(`error parsing response body "" on response with status code 400: unexpected end of JSON input`), @@ -570,7 +571,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when the server returns 400 Bad Request on the first call due to client auth, then any other error on second call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest, http.StatusForbidden}, returnErrBodies: []string{`{ "error":"invalid_client", "error_description":"unhappy" }`, ""}, wantErr: testutil.WantExactErrorString("server responded with status 403"), @@ -580,7 +581,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when server returns any other 400 error on first call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusBadRequest}, returnErrBodies: []string{`{ "error":"anything_else", "error_description":"unhappy" }`}, wantErr: testutil.WantExactErrorString(`server responded with status 400 with body: { "error":"anything_else", "error_description":"unhappy" }`), @@ -590,7 +591,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "error when server returns any other error aside from 400 on first call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusForbidden}, returnErrBodies: []string{""}, wantErr: testutil.WantExactErrorString("server responded with status 403"), @@ -600,7 +601,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "retryable error when server returns 503 on first call", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusServiceUnavailable}, // 503 returnErrBodies: []string{""}, wantErr: testutil.WantExactErrorString("retryable revocation error: server responded with status 503"), @@ -610,7 +611,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "retryable error when the server returns 400 Bad Request on the first call due to client auth, then 503 on second call", - tokenType: provider.AccessTokenType, + tokenType: upstreamprovider.AccessTokenType, returnStatusCodes: []int{http.StatusBadRequest, http.StatusServiceUnavailable}, // 400, 503 returnErrBodies: []string{`{ "error":"invalid_client", "error_description":"unhappy" }`, ""}, wantErr: testutil.WantExactErrorString("retryable revocation error: server responded with status 503"), @@ -620,7 +621,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "retryable error when server returns any 5xx status on first call, testing lower bound of 5xx range", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{http.StatusInternalServerError}, // 500 returnErrBodies: []string{""}, wantErr: testutil.WantExactErrorString("retryable revocation error: server responded with status 500"), @@ -630,7 +631,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "retryable error when server returns any 5xx status on first call, testing upper bound of 5xx range", - tokenType: provider.RefreshTokenType, + tokenType: upstreamprovider.RefreshTokenType, returnStatusCodes: []int{599}, // not defined by an RFC, but sometimes considered Network Connect Timeout Error returnErrBodies: []string{""}, wantErr: testutil.WantExactErrorString("retryable revocation error: server responded with status 599"), @@ -640,7 +641,7 @@ func TestProviderConfig(t *testing.T) { }, { name: "retryable error when the server cannot be reached", - tokenType: provider.AccessTokenType, + tokenType: upstreamprovider.AccessTokenType, unreachableServer: true, wantErr: testutil.WantMatchingErrorString("^retryable revocation error: Post .*: dial tcp .*: connect: connection refused$"), wantRetryableErrType: true, @@ -714,8 +715,8 @@ func TestProviderConfig(t *testing.T) { testutil.RequireErrorStringFromErr(t, err, tt.wantErr) if tt.wantRetryableErrType { - require.ErrorAs(t, err, &provider.RetryableRevocationError{}) - } else if errors.As(err, &provider.RetryableRevocationError{}) { + require.ErrorAs(t, err, &dynamicupstreamprovider.RetryableRevocationError{}) + } else if errors.As(err, &dynamicupstreamprovider.RetryableRevocationError{}) { // There is no NotErrorAs() assertion available in the current version of testify, so do the equivalent. require.Fail(t, "error should not be As RetryableRevocationError") } diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index b75f743e..fac2b99c 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -30,10 +30,10 @@ import ( "k8s.io/utils/strings/slices" oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/net/phttp" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/upstreamoidc" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -107,7 +107,7 @@ type handlerState struct { getEnv func(key string) string listen func(string, string) (net.Listener, error) isTTY func(int) bool - getProvider func(*oauth2.Config, *coreosoidc.Provider, *http.Client) provider.UpstreamOIDCIdentityProviderI + getProvider func(*oauth2.Config, *coreosoidc.Provider, *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI validateIDToken func(ctx context.Context, provider *coreosoidc.Provider, audience string, token string) (*coreosoidc.IDToken, error) promptForValue func(ctx context.Context, promptLabel string) (string, error) promptForSecret func(promptLabel string) (string, error) @@ -196,10 +196,11 @@ func WithSkipListen() Option { // SessionCacheKey contains the data used to select a valid session cache entry. type SessionCacheKey struct { - Issuer string `json:"issuer"` - ClientID string `json:"clientID"` - Scopes []string `json:"scopes"` - RedirectURI string `json:"redirect_uri"` + Issuer string `json:"issuer"` + ClientID string `json:"clientID"` + Scopes []string `json:"scopes"` + RedirectURI string `json:"redirect_uri"` + UpstreamProviderName string `json:"upstream_provider_name,omitempty"` } type SessionCache interface { @@ -351,6 +352,10 @@ func (h *handlerState) baseLogin() (*oidctypes.Token, error) { ClientID: h.clientID, Scopes: h.scopes, RedirectURI: (&url.URL{Scheme: "http", Host: h.listenAddr, Path: h.callbackPath}).String(), + // When using a Supervisor with multiple IDPs, the cache keys need to be different for each IDP + // so a user can have multiple sessions going for each IDP at the same time. + // When using a non-Supervisor OIDC provider, then this value will be blank, so it won't be part of the key. + UpstreamProviderName: h.upstreamIdentityProviderName, } // If the ID token is still valid for a bit, return it immediately and skip the rest of the flow. diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index ab393030..b041dbfc 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package oidcclient @@ -28,11 +28,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/klog/v2" + "go.pinniped.dev/internal/federationdomain/upstreamprovider" "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/roundtripper" "go.pinniped.dev/internal/mocks/mockupstreamoidcidentityprovider" "go.pinniped.dev/internal/net/phttp" - "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/testlogger" @@ -321,10 +321,11 @@ func TestLogin(t *testing.T) { //nolint:gocyclo cache := &mockSessionCache{t: t, getReturnsToken: nil} cacheKey := SessionCacheKey{ - Issuer: successServer.URL, - ClientID: "test-client-id", - Scopes: []string{"test-scope"}, - RedirectURI: "http://localhost:0/callback", + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: "some-upstream-name", } t.Cleanup(func() { require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) @@ -504,7 +505,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo return func(h *handlerState) error { require.NoError(t, WithClient(newClientForServer(successServer))(h)) - h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false). @@ -553,7 +554,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo return func(h *handlerState) error { require.NoError(t, WithClient(newClientForServer(successServer))(h)) - h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false). @@ -916,7 +917,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo wantToken: &testToken, }, { - name: "upstream name and type are included in authorize request if upstream name is provided", + name: "upstream name and type are included in authorize request and session cache key if upstream name is provided", clientID: "test-client-id", opt: func(t *testing.T) Option { return func(h *handlerState) error { @@ -926,10 +927,11 @@ func TestLogin(t *testing.T) { //nolint:gocyclo cache := &mockSessionCache{t: t, getReturnsToken: nil} cacheKey := SessionCacheKey{ - Issuer: successServer.URL, - ClientID: "test-client-id", - Scopes: []string{"test-scope"}, - RedirectURI: "http://localhost:0/callback", + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: "some-upstream-name", } t.Cleanup(func() { require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) @@ -1159,7 +1161,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo fmt.Sprintf("http://127.0.0.1:0/callback?code=%s&state=test-state", fakeAuthCode), }}, }, nil) - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens( @@ -1181,7 +1183,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo return func(h *handlerState) error { fakeAuthCode := "test-authcode-value" - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens( @@ -1207,10 +1209,11 @@ func TestLogin(t *testing.T) { //nolint:gocyclo cache := &mockSessionCache{t: t, getReturnsToken: nil} cacheKey := SessionCacheKey{ - Issuer: successServer.URL, - ClientID: "test-client-id", - Scopes: []string{"test-scope"}, - RedirectURI: "http://localhost:0/callback", + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: "some-upstream-name", } t.Cleanup(func() { require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) @@ -1281,7 +1284,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo return func(h *handlerState) error { fakeAuthCode := "test-authcode-value" - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens( @@ -1314,10 +1317,11 @@ func TestLogin(t *testing.T) { //nolint:gocyclo cache := &mockSessionCache{t: t, getReturnsToken: nil} cacheKey := SessionCacheKey{ - Issuer: successServer.URL, - ClientID: "test-client-id", - Scopes: []string{"test-scope"}, - RedirectURI: "http://localhost:0/callback", + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: "some-upstream-name", } t.Cleanup(func() { require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) @@ -1392,7 +1396,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo return func(h *handlerState) error { fakeAuthCode := "test-authcode-value" - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens( @@ -1425,10 +1429,11 @@ func TestLogin(t *testing.T) { //nolint:gocyclo cache := &mockSessionCache{t: t, getReturnsToken: nil} cacheKey := SessionCacheKey{ - Issuer: successServer.URL, - ClientID: "test-client-id", - Scopes: []string{"test-scope"}, - RedirectURI: "http://localhost:0/callback", + Issuer: successServer.URL, + ClientID: "test-client-id", + Scopes: []string{"test-scope"}, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: "some-upstream-name", } t.Cleanup(func() { require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys) @@ -1855,7 +1860,7 @@ func TestLogin(t *testing.T) { //nolint:gocyclo }) h.cache = cache - h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(config *oauth2.Config, provider *oidc.Provider, client *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ValidateTokenAndMergeWithUserInfo(gomock.Any(), HasAccessToken(testToken.AccessToken.Token), nonce.Nonce(""), true, false). @@ -1993,7 +1998,7 @@ func TestHandlePasteCallback(t *testing.T) { return "invalid", nil } h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "invalid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2017,7 +2022,7 @@ func TestHandlePasteCallback(t *testing.T) { return "valid", nil } h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2237,7 +2242,7 @@ func TestHandleAuthCodeCallback(t *testing.T) { opt: func(t *testing.T) Option { return func(h *handlerState) error { h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "invalid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2256,7 +2261,7 @@ func TestHandleAuthCodeCallback(t *testing.T) { opt: func(t *testing.T) Option { return func(h *handlerState) error { h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2282,7 +2287,7 @@ func TestHandleAuthCodeCallback(t *testing.T) { return func(h *handlerState) error { h.useFormPost = true h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2311,7 +2316,7 @@ func TestHandleAuthCodeCallback(t *testing.T) { return func(h *handlerState) error { h.useFormPost = true h.oauth2Config = &oauth2.Config{RedirectURL: testRedirectURI} - h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) provider.UpstreamOIDCIdentityProviderI { + h.getProvider = func(_ *oauth2.Config, _ *oidc.Provider, _ *http.Client) upstreamprovider.UpstreamOIDCIdentityProviderI { mock := mockUpstream(t) mock.EXPECT(). ExchangeAuthcodeAndValidateTokens(gomock.Any(), "valid", pkce.Code("test-pkce"), nonce.Nonce("test-nonce"), testRedirectURI). @@ -2333,7 +2338,7 @@ func TestHandleAuthCodeCallback(t *testing.T) { state: state.State("test-state"), pkce: pkce.Code("test-pkce"), nonce: nonce.Nonce("test-nonce"), - logger: plog.Logr(), //nolint:staticcheck // old test with no log assertions + logger: plog.Logr(), //nolint:staticcheck // old test with no log assertions issuer: "https://valid-issuer.com/with/some/path", } if tt.opt != nil { diff --git a/proposals/1113_ldap-ad-web-ui/README.md b/proposals/1113_ldap-ad-web-ui/README.md index 1a204c45..f892dc19 100644 --- a/proposals/1113_ldap-ad-web-ui/README.md +++ b/proposals/1113_ldap-ad-web-ui/README.md @@ -1,7 +1,7 @@ --- title: "Web UI for LDAP/AD login" authors: [ "@margocrawf" ] -status: "accepted" +status: "implemented" approval_date: "May 11, 2022" --- @@ -160,7 +160,7 @@ Once dynamic clients are implemented: #### New Dependencies This should be kept to a very simple HTML page with minimal, clean CSS styling. Javascript should be avoided. -The styling should match the [form post html page](https://github.com/vmware-tanzu/pinniped/tree/main/internal/oidc/provider/formposthtml) +The styling should match the [form post html page](https://github.com/vmware-tanzu/pinniped/tree/main/internal/federationdomain/formposthtml) as much as possible, we should reuse some of the existing css and add to it to keep the style consistent. #### Observability Considerations diff --git a/proposals/1125_dynamic-supervisor-oidc-clients/README.md b/proposals/1125_dynamic-supervisor-oidc-clients/README.md index c75b3704..dcb5dd86 100644 --- a/proposals/1125_dynamic-supervisor-oidc-clients/README.md +++ b/proposals/1125_dynamic-supervisor-oidc-clients/README.md @@ -1,7 +1,7 @@ --- title: "Dynamic Supervisor OIDC Clients" authors: [ "@cfryanr", "@enj" ] -status: "approved" +status: "implemented" sponsor: [ ] approval_date: "Jul 26, 2022" --- diff --git a/proposals/1141_audit-logging/README.md b/proposals/1141_audit-logging/README.md index 994b996c..aaf1d54b 100644 --- a/proposals/1141_audit-logging/README.md +++ b/proposals/1141_audit-logging/README.md @@ -1,7 +1,7 @@ --- title: "Audit Logging" authors: [ "@cfryanr" ] -status: "in-review" +status: "accepted" sponsor: [ ] approval_date: "" --- diff --git a/proposals/1406_multiple-idps/README.md b/proposals/1406_multiple-idps/README.md index d109f263..11b973a9 100644 --- a/proposals/1406_multiple-idps/README.md +++ b/proposals/1406_multiple-idps/README.md @@ -1,9 +1,9 @@ --- title: "Multiple Identity Providers" authors: [ "@cfryanr" ] -status: "draft" +status: "accepted" sponsor: [] -approval_date: "" +approval_date: "July 12, 2023" --- *Disclaimer*: Proposals are point-in-time designs and decisions. Once approved and implemented, they become historical diff --git a/site/content/docs/reference/code-walkthrough.md b/site/content/docs/reference/code-walkthrough.md index 11c84cd7..bd18b6f9 100644 --- a/site/content/docs/reference/code-walkthrough.md +++ b/site/content/docs/reference/code-walkthrough.md @@ -192,28 +192,28 @@ The Supervisor's endpoints are: Each FederationDomain's endpoints are mounted under the path of the FederationDomain's `spec.issuer`, if the `spec.issuer` URL has a path component specified. If the issuer has no path, then they are mounted under `/`. These per-FederationDomain endpoint are all mounted by the code in -[internal/oidc/provider/manager/manager.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/provider/manager/manager.go). +[internal/federationdomain/endpointsmanager/manager.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpointsmanager/manager.go). The per-FederationDomain endpoints are: - `/.well-known/openid-configuration` is the standard OIDC discovery endpoint, which can be used to discover all the other endpoints listed here. - See [internal/oidc/discovery/discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/discovery/discovery_handler.go). + See [internal/federationdomain/endpoints/discovery/discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/discovery/discovery_handler.go). - `/jwks.json` is the standard OIDC JWKS discovery endpoint. - See [internal/oidc/jwks/jwks_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/jwks/jwks_handler.go). + See [internal/federationdomain/endpoints/jwks/jwks_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/jwks/jwks_handler.go). - `/oauth2/authorize` is the standard OIDC authorize endpoint. - See [internal/oidc/auth/auth_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/auth/auth_handler.go). + See [internal/federationdomain/endpoints/auth/auth_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/auth/auth_handler.go). - `/oauth2/token` is the standard OIDC token endpoint. - See [internal/oidc/token/token_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token/token_handler.go). + See [internal/federationdomain/endpoints/token/token_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/token/token_handler.go). The token endpoint can handle the standard OIDC `authorization_code` and `refresh_token` grant types, and has also been - extended in [internal/oidc/token_exchange.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/token_exchange.go) + extended in [internal/federationdomain/endpoints/tokenexchange/token_exchange.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/tokenexchange/token_exchange.go) to handle an additional grant type for [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchanges to reduce the applicable scope (technically, the `aud` claim) of ID tokens. - `/callback` is a special endpoint that is used as the redirect URL when performing an OIDC authcode flow against an upstream OIDC identity provider as configured by an OIDCIdentityProvider custom resource. - See [internal/oidc/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/callback/callback_handler.go). + See [internal/federationdomain/endpoints/callback/callback_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/callback/callback_handler.go). - `/v1alpha1/pinniped_identity_providers` is a custom discovery endpoint for clients to learn about available upstream identity providers. - See [internal/oidc/idpdiscovery/idp_discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/idpdiscovery/idp_discovery_handler.go). + See [internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/idpdiscovery/idp_discovery_handler.go). - `/login` is a login UI page to support the optional browser-based login flow for LDAP and Active Directory identity providers. - See [internal/oidc/login/login_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/oidc/login/login_handler.go). + See [internal/federationdomain/endpoints/login/login_handler.go](https://github.com/vmware-tanzu/pinniped/blob/main/internal/federationdomain/endpoints/login/login_handler.go). The OIDC specifications implemented by the Supervisor can be found at [openid.net](https://openid.net/connect). diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index 7b43b692..b145d46a 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -29,7 +29,9 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" utilerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/utils/ptr" authv1alpha "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" @@ -100,16 +102,18 @@ func TestE2EFullIntegration_Browser(t *testing.T) { ) // Create the downstream FederationDomain and expect it to go into the success status condition. - downstream := testlib.CreateTestFederationDomain(topSetupCtx, t, - issuerURL.String(), - certSecret.Name, - configv1alpha1.SuccessFederationDomainStatusCondition, + federationDomain := testlib.CreateTestFederationDomain(topSetupCtx, t, + configv1alpha1.FederationDomainSpec{ + Issuer: issuerURL.String(), + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: certSecret.Name}, + }, + configv1alpha1.FederationDomainPhaseError, // in phase error until there is an IDP created ) // Create a JWTAuthenticator that will validate the tokens from the downstream issuer. clusterAudience := "test-cluster-" + testlib.RandHex(t, 8) authenticator := testlib.CreateTestJWTAuthenticator(topSetupCtx, t, authv1alpha.JWTAuthenticatorSpec{ - Issuer: downstream.Spec.Issuer, + Issuer: federationDomain.Spec.Issuer, Audience: clusterAudience, TLS: &authv1alpha.TLSSpec{CertificateAuthorityData: testCABundleBase64}, }) @@ -140,7 +144,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { }) // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -156,9 +160,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -168,6 +174,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-browser", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -182,8 +189,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. - t.Logf("waiting for response page %s", downstream.Spec.Issuer) - browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(downstream.Spec.Issuer))) + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) // The response page should have done the background fetch() and POST'ed to the CLI's callback. // It should now be in the "success" state. @@ -191,7 +198,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for @@ -221,7 +229,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { }) // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -237,9 +245,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -249,6 +259,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-browser", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, "--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups }) @@ -263,8 +274,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. - t.Logf("waiting for response page %s", downstream.Spec.Issuer) - browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(downstream.Spec.Issuer))) + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) // The response page should have done the background fetch() and POST'ed to the CLI's callback. // It should now be in the "success" state. @@ -276,8 +287,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted // scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will // assert that the expected username and groups claims/values are in the downstream ID token. - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, - pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"}) }) t.Run("with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) { @@ -305,7 +316,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { }) // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -321,9 +332,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -334,6 +347,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-listen", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -368,8 +382,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. - t.Logf("waiting for response page %s", downstream.Spec.Issuer) - browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(downstream.Spec.Issuer))) + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) // The response page should have failed to automatically post, and should now be showing the manual instructions. authCode := formpostExpectManualState(t, browser) @@ -388,7 +402,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("access token based refresh with Supervisor OIDC upstream IDP and manual authcode copy-paste from browser flow", func(t *testing.T) { @@ -424,7 +439,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { } // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -440,9 +455,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -453,6 +470,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-skip-listen", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -493,8 +511,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC) // Expect to be redirected to the downstream callback which is serving the form_post HTML. - t.Logf("waiting for response page %s", downstream.Spec.Issuer) - browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(downstream.Spec.Issuer))) + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) // The response page should have failed to automatically post, and should now be showing the manual instructions. authCode := formpostExpectManualState(t, browser) @@ -524,7 +542,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("with Supervisor OIDC upstream IDP and CLI password flow without web browser", func(t *testing.T) { @@ -549,7 +568,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { }) // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -566,9 +585,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -580,6 +601,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--upstream-identity-provider-flow", "cli_password", // create a kubeconfig configured to use the cli_password flow "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -607,7 +629,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) t.Run("with Supervisor OIDC upstream IDP and CLI password flow when OIDCIdentityProvider disallows it", func(t *testing.T) { @@ -634,9 +657,11 @@ func TestE2EFullIntegration_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -654,6 +679,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--upstream-identity-provider-flow", "cli_password", "--oidc-ca-bundle", testCABundlePath, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -705,10 +731,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -716,6 +744,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -743,7 +772,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // If the username and groups scope are not requested by the CLI, then the CLI still gets them, to allow for @@ -759,10 +789,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -770,6 +802,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, "--oidc-scopes", "offline_access,openid,pinniped:request-audience", // does not request username or groups }) @@ -801,8 +834,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { // The scopes portion of the cache key is made up of the requested scopes from the CLI flag, not the granted // scopes returned by the Supervisor, so list the requested scopes from the CLI flag here. This helper will // assert that the expected username and groups claims/values are in the downstream ID token. - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, - pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"}) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, []string{"offline_access", "openid", "pinniped:request-audience"}) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands @@ -818,10 +851,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -829,6 +864,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -868,7 +904,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { require.NoError(t, os.Unsetenv(usernameEnvVar)) require.NoError(t, os.Unsetenv(passwordEnvVar)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands @@ -884,10 +921,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue expectedGroups := env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames - setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -895,6 +934,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -922,7 +962,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { t.Logf("first kubectl command took %s", time.Since(start).String()) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an ActiveDirectory upstream IDP and try using it to authenticate during kubectl commands @@ -938,10 +979,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue expectedGroups := env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames - setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -949,6 +992,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--concierge-authenticator-type", "jwt", "--concierge-authenticator-name", authenticator.Name, "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -988,7 +1032,8 @@ func TestE2EFullIntegration_Browser(t *testing.T) { require.NoError(t, os.Unsetenv(usernameEnvVar)) require.NoError(t, os.Unsetenv(passwordEnvVar)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. @@ -1006,10 +1051,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -1020,6 +1067,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-ca-bundle", testCABundlePath, "--upstream-identity-provider-flow", "browser_authcode", "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -1031,14 +1079,15 @@ func TestE2EFullIntegration_Browser(t *testing.T) { kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser) // Confirm that we got to the Supervisor's login page, fill out the form, and submit the form. - browsertest.LoginToUpstreamLDAP(t, browser, downstream.Spec.Issuer, + browsertest.LoginToUpstreamLDAP(t, browser, federationDomain.Spec.Issuer, expectedUsername, env.SupervisorUpstreamLDAP.TestUserPassword) formpostExpectSuccessState(t, browser) requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an Active Directory upstream IDP and try using it to authenticate during kubectl commands, using the browser flow. @@ -1056,10 +1105,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue expectedGroups := env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames - setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndActiveDirectoryTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -1070,6 +1121,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-ca-bundle", testCABundlePath, "--upstream-identity-provider-flow", "browser_authcode", "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -1081,14 +1133,15 @@ func TestE2EFullIntegration_Browser(t *testing.T) { kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser) // Confirm that we got to the Supervisor's login page, fill out the form, and submit the form. - browsertest.LoginToUpstreamLDAP(t, browser, downstream.Spec.Issuer, + browsertest.LoginToUpstreamLDAP(t, browser, federationDomain.Spec.Issuer, expectedUsername, env.SupervisorUpstreamActiveDirectory.TestUserPassword) formpostExpectSuccessState(t, browser) requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) }) // Add an LDAP upstream IDP and try using it to authenticate during kubectl commands, using the env var to choose the browser flow. @@ -1106,10 +1159,12 @@ func TestE2EFullIntegration_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue expectedGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" kubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ "get", "kubeconfig", @@ -1120,6 +1175,7 @@ func TestE2EFullIntegration_Browser(t *testing.T) { "--oidc-ca-bundle", testCABundlePath, "--upstream-identity-provider-flow", "cli_password", // put cli_password in the kubeconfig, so we can override it with the env var "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, // use default for --oidc-scopes, which is to request all relevant scopes }) @@ -1137,14 +1193,340 @@ func TestE2EFullIntegration_Browser(t *testing.T) { kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser) // Confirm that we got to the Supervisor's login page, fill out the form, and submit the form. - browsertest.LoginToUpstreamLDAP(t, browser, downstream.Spec.Issuer, + browsertest.LoginToUpstreamLDAP(t, browser, federationDomain.Spec.Issuer, expectedUsername, env.SupervisorUpstreamLDAP.TestUserPassword) formpostExpectSuccessState(t, browser) requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) - requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, downstream, kubeconfigPath, sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, createdProvider.Name, kubeconfigPath, + sessionCachePath, pinnipedExe, expectedUsername, expectedGroups, allScopes) + }) + + t.Run("with multiple IDPs: one OIDC and one LDAP", func(t *testing.T) { + testlib.SkipTestWhenLDAPIsUnavailable(t, env) + + testCtx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + t.Cleanup(cancel) + + tempDir := t.TempDir() // per-test tmp dir to avoid sharing files between tests + + // Start a fresh browser driver because we don't want to share cookies between the various tests in this file. + browser := browsertest.OpenBrowser(t) + + downstreamPrefix := "pre:" + + expectedUpstreamLDAPUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue + expectedDownstreamLDAPUsername := downstreamPrefix + expectedUpstreamLDAPUsername + expectedUpstreamLDAPGroups := env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs + expectedDownstreamLDAPGroups := make([]string, 0, len(expectedUpstreamLDAPGroups)) + for _, g := range expectedUpstreamLDAPGroups { + expectedDownstreamLDAPGroups = append(expectedDownstreamLDAPGroups, downstreamPrefix+g) + } + + expectedUpstreamOIDCUsername := env.SupervisorUpstreamOIDC.Username + expectedDownstreamOIDCUsername := downstreamPrefix + expectedUpstreamOIDCUsername + expectedUpstreamOIDCGroups := env.SupervisorUpstreamOIDC.ExpectedGroups + expectedDownstreamOIDCGroups := make([]string, 0, len(expectedUpstreamOIDCGroups)) + for _, g := range expectedUpstreamOIDCGroups { + expectedDownstreamOIDCGroups = append(expectedDownstreamOIDCGroups, downstreamPrefix+g) + } + + createdLDAPProvider := setupClusterForEndToEndLDAPTest(t, expectedDownstreamLDAPUsername, env) + + // Having one IDP should put the FederationDomain into a ready state. + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) + + // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. + testlib.CreateTestClusterRoleBinding(t, + rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: expectedDownstreamOIDCUsername}, + rbacv1.RoleRef{Kind: "ClusterRole", APIGroup: rbacv1.GroupName, Name: "view"}, + ) + testlib.WaitForUserToHaveAccess(t, expectedDownstreamOIDCUsername, []string{}, &authorizationv1.ResourceAttributes{ + Verb: "get", + Group: "", + Version: "v1", + Resource: "namespaces", + }) + + // Create upstream OIDC provider and wait for it to become ready. + createdOIDCProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: env.SupervisorUpstreamOIDC.Issuer, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), + }, + AuthorizationConfig: idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, + }, + Claims: idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + }, + Client: idpv1alpha1.OIDCClient{ + SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, + }, + }, idpv1alpha1.PhaseReady) + + // Having a second IDP should put the FederationDomain back into an error state until we tell it which one to use. + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseError) + + // Update the FederationDomain to use the two IDPs. + federationDomainsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(env.SupervisorNamespace) + gotFederationDomain, err := federationDomainsClient.Get(testCtx, federationDomain.Name, metav1.GetOptions{}) + require.NoError(t, err) + + ldapIDPDisplayName := "My LDAP IDP 💾" + oidcIDPDisplayName := "My OIDC IDP 🚀" + + gotFederationDomain.Spec.IdentityProviders = []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: ldapIDPDisplayName, + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "LDAPIdentityProvider", + Name: createdLDAPProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + {Name: "allowedUser", Type: "string", StringValue: expectedUpstreamLDAPUsername}, + {Name: "allowedUsers", Type: "stringList", StringListValue: []string{"someone else", expectedUpstreamLDAPUsername, "someone else"}}, + }, + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "policy/v1", Expression: `username == strConst.allowedUser && username in strListConst.allowedUsers`, Message: "only special users allowed"}, + {Type: "username/v1", Expression: fmt.Sprintf(`"%s" + username`, downstreamPrefix)}, + {Type: "groups/v1", Expression: fmt.Sprintf(`groups.map(g, "%s" + g)`, downstreamPrefix)}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { + Username: expectedUpstreamLDAPUsername, + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: expectedDownstreamLDAPUsername, + Groups: []string{downstreamPrefix + "a", downstreamPrefix + "b"}, + }, + }, + { + Username: "someone other user", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only special users allowed", + }, + }, + }, + }, + }, + { + DisplayName: oidcIDPDisplayName, + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: createdOIDCProvider.Name, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Constants: []configv1alpha1.FederationDomainTransformsConstant{ + {Name: "allowedUser", Type: "string", StringValue: expectedUpstreamOIDCUsername}, + {Name: "allowedUsers", Type: "stringList", StringListValue: []string{"someone else", expectedUpstreamOIDCUsername, "someone else"}}, + }, + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + {Type: "policy/v1", Expression: `username == strConst.allowedUser && username in strListConst.allowedUsers`, Message: "only special users allowed"}, + {Type: "username/v1", Expression: fmt.Sprintf(`"%s" + username`, downstreamPrefix)}, + {Type: "groups/v1", Expression: fmt.Sprintf(`groups.map(g, "%s" + g)`, downstreamPrefix)}, + }, + Examples: []configv1alpha1.FederationDomainTransformsExample{ + { + Username: expectedUpstreamOIDCUsername, + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Username: expectedDownstreamOIDCUsername, + Groups: []string{downstreamPrefix + "a", downstreamPrefix + "b"}, + }, + }, + { + Username: "someone other user", + Groups: []string{"a", "b"}, + Expects: configv1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only special users allowed", + }, + }, + }, + }, + }, + } + _, err = federationDomainsClient.Update(testCtx, gotFederationDomain, metav1.UpdateOptions{}) + require.NoError(t, err) + + // The FederationDomain should be valid after the above update. + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) + + // Use a specific session cache for this test. + sessionCachePath := tempDir + "/test-sessions.yaml" + credentialCachePath := tempDir + "/test-credentials.yaml" + + ldapKubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, + "--upstream-identity-provider-name", ldapIDPDisplayName, + // use default for --oidc-scopes, which is to request all relevant scopes + }) + + oidcKubeconfigPath := runPinnipedGetKubeconfig(t, env, pinnipedExe, tempDir, []string{ + "get", "kubeconfig", + "--concierge-api-group-suffix", env.APIGroupSuffix, + "--concierge-authenticator-type", "jwt", + "--concierge-authenticator-name", authenticator.Name, + "--oidc-skip-browser", + "--oidc-session-cache", sessionCachePath, + "--credential-cache", credentialCachePath, + "--upstream-identity-provider-name", oidcIDPDisplayName, + // use default for --oidc-scopes, which is to request all relevant scopes + }) + + // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin for the LDAP IDP. + t.Log("starting LDAP auth via kubectl") + start := time.Now() + kubectlCmd := exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", ldapKubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + ptyFile, err := pty.Start(kubectlCmd) + require.NoError(t, err) + + // Wait for the subprocess to print the username prompt, then type the user's username. + readFromFileUntilStringIsSeen(t, ptyFile, "Username: ") + _, err = ptyFile.WriteString(expectedUpstreamLDAPUsername + "\n") + require.NoError(t, err) + + // Wait for the subprocess to print the password prompt, then type the user's password. + readFromFileUntilStringIsSeen(t, ptyFile, "Password: ") + _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") + require.NoError(t, err) + + // Read all output from the subprocess until EOF. + // Ignore any errors returned because there is always an error on linux. + kubectlOutputBytes, _ := io.ReadAll(ptyFile) + requireKubectlGetNamespaceOutput(t, env, string(kubectlOutputBytes)) + + t.Logf("first kubectl command took %s", time.Since(start).String()) + + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, ldapIDPDisplayName, ldapKubeconfigPath, + sessionCachePath, pinnipedExe, expectedDownstreamLDAPUsername, expectedDownstreamLDAPGroups, allScopes) + + // Run "kubectl get namespaces" which should trigger a browser login via the plugin for the OIDC IDP. + t.Log("starting OIDC auth via kubectl") + kubectlCmd = exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", oidcKubeconfigPath, "-v", "6") + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + + // Run the kubectl command, wait for the Pinniped CLI to print the authorization URL, and open it in the browser. + kubectlOutputChan := startKubectlAndOpenAuthorizationURLInBrowser(testCtx, t, kubectlCmd, browser) + + // Confirm that we got to the upstream IDP's login page, fill out the form, and submit the form. + browsertest.LoginToUpstreamOIDC(t, browser, env.SupervisorUpstreamOIDC) + + // Expect to be redirected to the downstream callback which is serving the form_post HTML. + t.Logf("waiting for response page %s", federationDomain.Spec.Issuer) + browser.WaitForURL(t, regexp.MustCompile(regexp.QuoteMeta(federationDomain.Spec.Issuer))) + + // The response page should have done the background fetch() and POST'ed to the CLI's callback. + // It should now be in the "success" state. + formpostExpectSuccessState(t, browser) + + requireKubectlGetNamespaceOutput(t, env, waitForKubectlOutput(t, kubectlOutputChan)) + + // The user is now logged in to the cluster as two different identities simultaneously, and can switch + // back and forth by switching kubeconfigs, without needing to auth again. + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, oidcIDPDisplayName, oidcKubeconfigPath, + sessionCachePath, pinnipedExe, expectedDownstreamOIDCUsername, expectedDownstreamOIDCGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, ldapIDPDisplayName, ldapKubeconfigPath, + sessionCachePath, pinnipedExe, expectedDownstreamLDAPUsername, expectedDownstreamLDAPGroups, allScopes) + requireUserCanUseKubectlWithoutAuthenticatingAgain(testCtx, t, env, federationDomain, oidcIDPDisplayName, oidcKubeconfigPath, + sessionCachePath, pinnipedExe, expectedDownstreamOIDCUsername, expectedDownstreamOIDCGroups, allScopes) + + // Update the policies of both IDPs on the FederationDomain to reject the expected upstream usernames during authentication. + // Remove the examples since we are changing the transforms. + _, err = federationDomainsClient.Patch(testCtx, gotFederationDomain.Name, types.JSONPatchType, + []byte(here.Doc( + `[ + { + "op": "replace", + "path": "/spec/identityProviders/0/transforms/expressions/0", + "value": { + "type": "policy/v1", + "expression": "username != strConst.allowedUser", + "message": "only special LDAP users allowed" + } + }, + { + "op": "replace", + "path": "/spec/identityProviders/1/transforms/expressions/0", + "value": { + "type": "policy/v1", + "expression": "username != strConst.allowedUser", + "message": "only special OIDC users allowed" + } + }, + { + "op": "remove", + "path": "/spec/identityProviders/0/transforms/examples" + }, + { + "op": "remove", + "path": "/spec/identityProviders/1/transforms/examples" + } + ]`, + )), + metav1.PatchOptions{}, + ) + require.NoError(t, err) + + // Wait for the status conditions to have observed the current spec generation so we can be sure that the + // controller has observed our latest update. + testlib.RequireEventually(t, func(requireEventually *require.Assertions) { + fd, err := federationDomainsClient.Get(testCtx, federationDomain.Name, metav1.GetOptions{}) + require.NoError(t, err) + t.Log("saw FederationDomain", fd) + requireEventually.Equal(fd.Generation, fd.Status.Conditions[0].ObservedGeneration) + }, 20*time.Second, 250*time.Millisecond) + // The FederationDomain should be valid after the above update. + testlib.WaitForFederationDomainStatusPhase(testCtx, t, federationDomain.Name, configv1alpha1.FederationDomainPhaseReady) + + // Log out so we can try fresh logins again. + require.NoError(t, os.Remove(credentialCachePath)) + require.NoError(t, os.Remove(sessionCachePath)) + + // Policies don't impact the kubeconfig files, so we can reuse the existing kubeconfig files. + // Try to log again, and this time expect to be rejected by the configured policies. + + // Run "kubectl get namespaces" which should trigger an LDAP-style login CLI prompt via the plugin for the LDAP IDP. + t.Log("starting second LDAP auth via kubectl") + kubectlCmd = exec.CommandContext(testCtx, "kubectl", "get", "namespace", "--kubeconfig", ldapKubeconfigPath) + kubectlCmd.Env = append(os.Environ(), env.ProxyEnv()...) + ptyFile, err = pty.Start(kubectlCmd) + require.NoError(t, err) + + // Wait for the subprocess to print the username prompt, then type the user's username. + readFromFileUntilStringIsSeen(t, ptyFile, "Username: ") + _, err = ptyFile.WriteString(expectedUpstreamLDAPUsername + "\n") + require.NoError(t, err) + + // Wait for the subprocess to print the password prompt, then type the user's password. + readFromFileUntilStringIsSeen(t, ptyFile, "Password: ") + _, err = ptyFile.WriteString(env.SupervisorUpstreamLDAP.TestUserPassword + "\n") + require.NoError(t, err) + + // Read all output from the subprocess until EOF. + // Ignore any errors returned because there is always an error on linux. + kubectlOutputBytes, _ = io.ReadAll(ptyFile) + t.Log("kubectl command output:\n", string(kubectlOutputBytes)) + require.Contains(t, string(kubectlOutputBytes), + `Error: could not complete Pinniped login: login failed with code "access_denied": `+ + `The resource owner or authorization server denied the request. `+ + `Reason: configured identity policy rejected this authentication: only special LDAP users allowed.`) + require.Contains(t, string(kubectlOutputBytes), "pinniped failed with exit code 1") }) } @@ -1241,7 +1623,7 @@ func waitForKubectlOutput(t *testing.T, kubectlOutputChan chan string) string { return kubectlOutput } -func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib.TestEnv) { +func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib.TestEnv) *idpv1alpha1.LDAPIdentityProvider { // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. testlib.CreateTestClusterRoleBinding(t, rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: username}, @@ -1263,7 +1645,7 @@ func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib ) // Create upstream LDAP provider and wait for it to become ready. - testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ + return testlib.CreateTestLDAPIdentityProvider(t, idpv1alpha1.LDAPIdentityProviderSpec{ Host: env.SupervisorUpstreamLDAP.Host, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.CABundle)), @@ -1289,7 +1671,7 @@ func setupClusterForEndToEndLDAPTest(t *testing.T, username string, env *testlib }, idpv1alpha1.LDAPPhaseReady) } -func setupClusterForEndToEndActiveDirectoryTest(t *testing.T, username string, env *testlib.TestEnv) { +func setupClusterForEndToEndActiveDirectoryTest(t *testing.T, username string, env *testlib.TestEnv) *idpv1alpha1.ActiveDirectoryIdentityProvider { // Create a ClusterRoleBinding to give our test user from the upstream read-only access to the cluster. testlib.CreateTestClusterRoleBinding(t, rbacv1.Subject{Kind: rbacv1.UserKind, APIGroup: rbacv1.GroupName, Name: username}, @@ -1311,7 +1693,7 @@ func setupClusterForEndToEndActiveDirectoryTest(t *testing.T, username string, e ) // Create upstream LDAP provider and wait for it to become ready. - testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ + return testlib.CreateTestActiveDirectoryIdentityProvider(t, idpv1alpha1.ActiveDirectoryIdentityProviderSpec{ Host: env.SupervisorUpstreamActiveDirectory.Host, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamActiveDirectory.CABundle)), @@ -1369,6 +1751,7 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain( t *testing.T, env *testlib.TestEnv, downstream *configv1alpha1.FederationDomain, + upstreamProviderName string, kubeconfigPath string, sessionCachePath string, pinnipedExe string, @@ -1392,10 +1775,11 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain( sort.Strings(downstreamScopes) token := cache.GetToken(oidcclient.SessionCacheKey{ - Issuer: downstream.Spec.Issuer, - ClientID: "pinniped-cli", - Scopes: downstreamScopes, - RedirectURI: "http://localhost:0/callback", + Issuer: downstream.Spec.Issuer, + ClientID: "pinniped-cli", + Scopes: downstreamScopes, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: upstreamProviderName, }) require.NotNil(t, token) @@ -1498,7 +1882,7 @@ func runPinnipedGetKubeconfig(t *testing.T, env *testlib.TestEnv, pinnipedExe st require.NotNil(t, restConfig.ExecProvider) require.Equal(t, []string{"login", "oidc"}, restConfig.ExecProvider.Args[:2]) - kubeconfigPath := filepath.Join(tempDir, "kubeconfig.yaml") + kubeconfigPath := filepath.Join(tempDir, fmt.Sprintf("kubeconfig-%s.yaml", testlib.RandHex(t, 8))) require.NoError(t, os.WriteFile(kubeconfigPath, []byte(kubeconfigYAML), 0600)) return kubeconfigPath diff --git a/test/integration/formposthtml_test.go b/test/integration/formposthtml_test.go index 1a3b4529..9056e065 100644 --- a/test/integration/formposthtml_test.go +++ b/test/integration/formposthtml_test.go @@ -19,8 +19,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.pinniped.dev/internal/federationdomain/formposthtml" "go.pinniped.dev/internal/httputil/securityheader" - "go.pinniped.dev/internal/oidc/provider/formposthtml" "go.pinniped.dev/test/testlib" "go.pinniped.dev/test/testlib/browsertest" ) diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index 38aabd13..8d2e7267 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -441,7 +441,7 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr // over time, make a rudimentary assertion that this test exercised the whole tree of all fields of all // Pinniped API resources. Without this, the test could accidentally skip parts of the tree if the // format has changed. - require.Equal(t, 230, foundFieldNames, + require.Equal(t, 259, foundFieldNames, "Expected to find all known fields of all Pinniped API resources. "+ "You may will need to update this expectation if you added new fields to the API types.", ) @@ -558,7 +558,7 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) { addSuffix("federationdomains.config.supervisor"): { "v1alpha1": []apiextensionsv1.CustomResourceColumnDefinition{ {Name: "Issuer", Type: "string", JSONPath: ".spec.issuer"}, - {Name: "Status", Type: "string", JSONPath: ".status.status"}, + {Name: "Status", Type: "string", JSONPath: ".status.phase"}, {Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"}, }, }, diff --git a/test/integration/supervisor_discovery_test.go b/test/integration/supervisor_discovery_test.go index 9813d958..461ac7f4 100644 --- a/test/integration/supervisor_discovery_test.go +++ b/test/integration/supervisor_discovery_test.go @@ -23,8 +23,10 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" "k8s.io/client-go/util/retry" + "k8s.io/utils/strings/slices" "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/crypto/ptls" @@ -82,6 +84,12 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) { t.Skip("no address defined") } + // Create any IDP so that any FederationDomain created later by this test will see that exactly one IDP exists. + testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + // Test that there is no default discovery endpoint available when there are no FederationDomains. requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, fmt.Sprintf("%s://%s", scheme, addr)) @@ -124,15 +132,15 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) { // When the same issuer is added twice, both issuers are marked as duplicates, and neither provider is serving. config6Duplicate1, _ := requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear(ctx, t, scheme, addr, caBundle, issuer6, client) - config6Duplicate2 := testlib.CreateTestFederationDomain(ctx, t, issuer6, "", "") - requireStatus(t, client, ns, config6Duplicate1.Name, v1alpha1.DuplicateFederationDomainStatusCondition) - requireStatus(t, client, ns, config6Duplicate2.Name, v1alpha1.DuplicateFederationDomainStatusCondition) + config6Duplicate2 := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{Issuer: issuer6}, v1alpha1.FederationDomainPhaseError) + requireStatus(t, client, ns, config6Duplicate1.Name, v1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerIsUnique"})) + requireStatus(t, client, ns, config6Duplicate2.Name, v1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerIsUnique"})) requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, issuer6) // If we delete the first duplicate issuer, the second duplicate issuer starts serving. requireDelete(t, client, ns, config6Duplicate1.Name) requireWellKnownEndpointIsWorking(t, scheme, addr, caBundle, issuer6, nil) - requireStatus(t, client, ns, config6Duplicate2.Name, v1alpha1.SuccessFederationDomainStatusCondition) + requireStatus(t, client, ns, config6Duplicate2.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) // When we finally delete all issuers, the endpoint should be down. requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config6Duplicate2, client, ns, scheme, addr, caBundle, issuer6) @@ -143,8 +151,8 @@ func TestSupervisorOIDCDiscovery_Disruptive(t *testing.T) { requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, config7, client, ns, scheme, addr, caBundle, issuer7) // When we create a provider with an invalid issuer, the status is set to invalid. - badConfig := testlib.CreateTestFederationDomain(ctx, t, badIssuer, "", "") - requireStatus(t, client, ns, badConfig.Name, v1alpha1.InvalidFederationDomainStatusCondition) + badConfig := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{Issuer: badIssuer}, v1alpha1.FederationDomainPhaseError) + requireStatus(t, client, ns, badConfig.Name, v1alpha1.FederationDomainPhaseError, withFalseConditions([]string{"Ready", "IssuerURLValid"})) requireDiscoveryEndpointsAreNotFound(t, scheme, addr, caBundle, badIssuer) requireDeletingFederationDomainCausesDiscoveryEndpointsToDisappear(t, badConfig, client, ns, scheme, addr, caBundle, badIssuer) }) @@ -161,6 +169,12 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() + // Create any IDP so that any FederationDomain created later by this test will see that exactly one IDP exists. + testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + temporarilyRemoveAllFederationDomainsAndDefaultTLSCertSecret(ctx, t, ns, defaultTLSCertSecretName(env), pinnipedClient, kubeClient) scheme := "https" @@ -171,8 +185,12 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { certSecretName1 := "integration-test-cert-1" // Create an FederationDomain with a spec.tls.secretName. - federationDomain1 := testlib.CreateTestFederationDomain(ctx, t, issuer1, certSecretName1, "") - requireStatus(t, pinnipedClient, federationDomain1.Namespace, federationDomain1.Name, v1alpha1.SuccessFederationDomainStatusCondition) + federationDomain1 := testlib.CreateTestFederationDomain(ctx, t, + v1alpha1.FederationDomainSpec{ + Issuer: issuer1, + TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: certSecretName1}, + }, v1alpha1.FederationDomainPhaseReady) + requireStatus(t, pinnipedClient, federationDomain1.Namespace, federationDomain1.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) // The spec.tls.secretName Secret does not exist, so the endpoints should fail with TLS errors. requireEndpointHasBootstrapTLSErrorBecauseCertificatesAreNotReady(t, issuer1) @@ -195,7 +213,7 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { return err })) - // The the endpoints should fail with TLS errors again. + // The endpoints should fail with TLS errors again. requireEndpointHasBootstrapTLSErrorBecauseCertificatesAreNotReady(t, issuer1) // Create a Secret at the updated name. @@ -211,8 +229,12 @@ func TestSupervisorTLSTerminationWithSNI_Disruptive(t *testing.T) { certSecretName2 := "integration-test-cert-2" // Create an FederationDomain with a spec.tls.secretName. - federationDomain2 := testlib.CreateTestFederationDomain(ctx, t, issuer2, certSecretName2, "") - requireStatus(t, pinnipedClient, federationDomain2.Namespace, federationDomain2.Name, v1alpha1.SuccessFederationDomainStatusCondition) + federationDomain2 := testlib.CreateTestFederationDomain(ctx, t, + v1alpha1.FederationDomainSpec{ + Issuer: issuer2, + TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: certSecretName2}, + }, v1alpha1.FederationDomainPhaseReady) + requireStatus(t, pinnipedClient, federationDomain2.Namespace, federationDomain2.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) // Create the Secret. ca2 := createTLSCertificateSecret(ctx, t, ns, hostname2, nil, certSecretName2, kubeClient) @@ -233,6 +255,12 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) defer cancel() + // Create any IDP so that any FederationDomain created later by this test will see that exactly one IDP exists. + testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + temporarilyRemoveAllFederationDomainsAndDefaultTLSCertSecret(ctx, t, ns, defaultTLSCertSecretName(env), pinnipedClient, kubeClient) scheme := "https" @@ -255,8 +283,8 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) { issuerUsingHostname := fmt.Sprintf("%s://%s/issuer1", scheme, address) // Create an FederationDomain without a spec.tls.secretName. - federationDomain1 := testlib.CreateTestFederationDomain(ctx, t, issuerUsingIPAddress, "", "") - requireStatus(t, pinnipedClient, federationDomain1.Namespace, federationDomain1.Name, v1alpha1.SuccessFederationDomainStatusCondition) + federationDomain1 := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{Issuer: issuerUsingIPAddress}, v1alpha1.FederationDomainPhaseReady) + requireStatus(t, pinnipedClient, federationDomain1.Namespace, federationDomain1.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) // There is no default TLS cert and the spec.tls.secretName was not set, so the endpoints should fail with TLS errors. requireEndpointHasBootstrapTLSErrorBecauseCertificatesAreNotReady(t, issuerUsingIPAddress) @@ -269,8 +297,12 @@ func TestSupervisorTLSTerminationWithDefaultCerts_Disruptive(t *testing.T) { // Create an FederationDomain with a spec.tls.secretName. certSecretName := "integration-test-cert-1" - federationDomain2 := testlib.CreateTestFederationDomain(ctx, t, issuerUsingHostname, certSecretName, "") - requireStatus(t, pinnipedClient, federationDomain2.Namespace, federationDomain2.Name, v1alpha1.SuccessFederationDomainStatusCondition) + federationDomain2 := testlib.CreateTestFederationDomain(ctx, t, + v1alpha1.FederationDomainSpec{ + Issuer: issuerUsingHostname, + TLS: &v1alpha1.FederationDomainTLSSpec{SecretName: certSecretName}, + }, v1alpha1.FederationDomainPhaseReady) + requireStatus(t, pinnipedClient, federationDomain2.Namespace, federationDomain2.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) // Create the Secret. certCA := createTLSCertificateSecret(ctx, t, ns, hostname, nil, certSecretName, kubeClient) @@ -456,9 +488,9 @@ func requireCreatingFederationDomainCausesDiscoveryEndpointsToAppear( client pinnipedclientset.Interface, ) (*v1alpha1.FederationDomain, *ExpectedJWKSResponseFormat) { t.Helper() - newFederationDomain := testlib.CreateTestFederationDomain(ctx, t, issuerName, "", "") + newFederationDomain := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{Issuer: issuerName}, v1alpha1.FederationDomainPhaseReady) jwksResult := requireDiscoveryEndpointsAreWorking(t, supervisorScheme, supervisorAddress, supervisorCABundle, issuerName, nil) - requireStatus(t, client, newFederationDomain.Namespace, newFederationDomain.Name, v1alpha1.SuccessFederationDomainStatusCondition) + requireStatus(t, client, newFederationDomain.Namespace, newFederationDomain.Name, v1alpha1.FederationDomainPhaseReady, withAllSuccessfulConditions()) return newFederationDomain, jwksResult } @@ -626,7 +658,34 @@ func requireDelete(t *testing.T, client pinnipedclientset.Interface, ns, name st require.NoError(t, err) } -func requireStatus(t *testing.T, client pinnipedclientset.Interface, ns, name string, status v1alpha1.FederationDomainStatusCondition) { +func withAllSuccessfulConditions() map[string]metav1.ConditionStatus { + return map[string]metav1.ConditionStatus{ + "Ready": metav1.ConditionTrue, + "IssuerIsUnique": metav1.ConditionTrue, + "IdentityProvidersFound": metav1.ConditionTrue, + "OneTLSSecretPerIssuerHostname": metav1.ConditionTrue, + "IssuerURLValid": metav1.ConditionTrue, + "IdentityProvidersObjectRefKindValid": metav1.ConditionTrue, + "IdentityProvidersObjectRefAPIGroupSuffixValid": metav1.ConditionTrue, + "IdentityProvidersDisplayNamesUnique": metav1.ConditionTrue, + "TransformsExpressionsValid": metav1.ConditionTrue, + "TransformsExamplesPassed": metav1.ConditionTrue, + } +} + +func withFalseConditions(falseConditionTypes []string) map[string]metav1.ConditionStatus { + c := map[string]metav1.ConditionStatus{} + for k, v := range withAllSuccessfulConditions() { + if slices.Contains(falseConditionTypes, k) { + c[k] = metav1.ConditionFalse + } else { + c[k] = v + } + } + return c +} + +func requireStatus(t *testing.T, client pinnipedclientset.Interface, ns, name string, wantPhase v1alpha1.FederationDomainPhase, wantConditionTypeToStatus map[string]metav1.ConditionStatus) { t.Helper() testlib.RequireEventually(t, func(requireEventually *require.Assertions) { @@ -636,8 +695,16 @@ func requireStatus(t *testing.T, client pinnipedclientset.Interface, ns, name st federationDomain, err := client.ConfigV1alpha1().FederationDomains(ns).Get(ctx, name, metav1.GetOptions{}) requireEventually.NoError(err) - t.Logf("found FederationDomain %s/%s with status %s", ns, name, federationDomain.Status.Status) - requireEventually.Equalf(status, federationDomain.Status.Status, "unexpected status (message = '%s')", federationDomain.Status.Message) + actualPhase := federationDomain.Status.Phase + t.Logf("found FederationDomain %s/%s with phase %s, wanted phase %s", ns, name, actualPhase, wantPhase) + requireEventually.Equalf(wantPhase, actualPhase, "unexpected phase (conditions = '%#v')", federationDomain.Status.Conditions) + + actualConditionTypeToStatus := map[string]metav1.ConditionStatus{} + for _, c := range federationDomain.Status.Conditions { + actualConditionTypeToStatus[c.Type] = c.Status + } + t.Logf("found FederationDomain %s/%s with conditions %#v, wanted condtions %#v", ns, name, actualConditionTypeToStatus, wantConditionTypeToStatus) + requireEventually.Equal(wantConditionTypeToStatus, actualConditionTypeToStatus, "unexpected statuses for conditions by type") }, 5*time.Minute, 200*time.Millisecond) } diff --git a/test/integration/supervisor_federationdomain_status_test.go b/test/integration/supervisor_federationdomain_status_test.go new file mode 100644 index 00000000..454d19de --- /dev/null +++ b/test/integration/supervisor_federationdomain_status_test.go @@ -0,0 +1,1026 @@ +// Copyright 2023 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "k8s.io/utils/ptr" + + "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" + "go.pinniped.dev/internal/here" + "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/test/testlib" +) + +// Never run this test in parallel since deleting all federation domains is disruptive, see main_test.go. +func TestSupervisorFederationDomainStatus_Disruptive(t *testing.T) { + env := testlib.IntegrationEnv(t) + supervisorClient := testlib.NewSupervisorClientset(t) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + + temporarilyRemoveAllFederationDomainsAndDefaultTLSCertSecret(ctx, t, + env.SupervisorNamespace, defaultTLSCertSecretName(env), supervisorClient, testlib.NewKubernetesClientset(t)) + + tests := []struct { + name string + run func(t *testing.T) + }{ + { + name: "valid spec in without explicit identity providers makes status error unless there is exactly one identity provider", + run: func(t *testing.T) { + // Creating FederationDomain without any explicit IDPs should put the FederationDomain into an error status. + fd := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com/fake", + }, v1alpha1.FederationDomainPhaseError) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulLegacyFederationDomainConditions("", fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "LegacyConfigurationIdentityProviderNotFound", + Message: "no resources were specified by .spec.identityProviders[].objectRef and no identity provider resources have been found: please create an identity provider resource", + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + }, + )) + + // Creating an IDP should put the FederationDomain into a successful status. + oidcIdentityProvider1 := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseReady) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, + allSuccessfulLegacyFederationDomainConditions(oidcIdentityProvider1.Name, fd.Spec)) + + // Creating a second IDP should put the FederationDomain back into an error status again. + oidcIdentityProvider2 := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseError) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulLegacyFederationDomainConditions(oidcIdentityProvider2.Name, fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProviderNotSpecified", + Message: "no resources were specified by .spec.identityProviders[].objectRef and 2 identity provider " + + "resources have been found: please update .spec.identityProviders to specify which identity providers " + + "this federation domain should use", + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + }, + )) + }, + }, + { + name: "valid spec with explicit identity providers makes status error until those identity providers all exist", + run: func(t *testing.T) { + oidcIDP1Meta := testlib.ObjectMetaWithRandomName(t, "upstream-oidc-idp") + oidcIDP2Meta := testlib.ObjectMetaWithRandomName(t, "upstream-oidc-idp") + // Creating FederationDomain with explicit IDPs that don't exist should put the FederationDomain into an error status. + fd := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com/fake", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "idp1", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: oidcIDP1Meta.Name, + }, + Transforms: v1alpha1.FederationDomainTransforms{}, + }, + { + DisplayName: "idp2", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: oidcIDP2Meta.Name, + }, + Transforms: v1alpha1.FederationDomainTransforms{}, + }, + }, + }, v1alpha1.FederationDomainPhaseError) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulFederationDomainConditions(fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProvidersObjectRefsNotFound", + Message: here.Docf(` + cannot find resource specified by .spec.identityProviders[0].objectRef (with name "%s") + + cannot find resource specified by .spec.identityProviders[1].objectRef (with name "%s")`, + oidcIDP1Meta.Name, oidcIDP2Meta.Name), + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + }, + )) + + // Creating the first IDP should not be enough to put the FederationDomain into a successful status. + oidcIdentityProvider1 := testlib.CreateTestOIDCIdentityProviderWithObjectMeta(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, oidcIDP1Meta, idpv1alpha1.PhaseError) + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseError) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulFederationDomainConditions(fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProvidersObjectRefsNotFound", + Message: fmt.Sprintf(`cannot find resource specified by .spec.identityProviders[1].objectRef (with name "%s")`, oidcIDP2Meta.Name), + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + }, + )) + + // Creating the second IDP should put the FederationDomain into a successful status. + testlib.CreateTestOIDCIdentityProviderWithObjectMeta(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, oidcIDP2Meta, idpv1alpha1.PhaseError) + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseReady) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, + allSuccessfulFederationDomainConditions(fd.Spec)) + + // Removing one IDP should put the FederationDomain back into an error status again. + oidcIDPClient := supervisorClient.IDPV1alpha1().OIDCIdentityProviders(env.SupervisorNamespace) + err := oidcIDPClient.Delete(ctx, oidcIdentityProvider1.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseError) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulFederationDomainConditions(fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProvidersObjectRefsNotFound", + Message: fmt.Sprintf(`cannot find resource specified by .spec.identityProviders[0].objectRef (with name "%s")`, oidcIDP1Meta.Name), + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + }, + )) + }, + }, + { + name: "spec with explicit identity providers and lots of validation errors", + run: func(t *testing.T) { + federationDomainsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(env.SupervisorNamespace) + + oidcIdentityProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + Issuer: "https://example.cluster.local/fake-issuer-url-does-not-matter", + Client: idpv1alpha1.OIDCClient{SecretName: "this-will-not-exist-but-does-not-matter"}, + }, idpv1alpha1.PhaseError) + + fd := testlib.CreateTestFederationDomain(ctx, t, v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com/fake", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "not unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("this is the wrong api group"), + Kind: "OIDCIdentityProvider", + Name: "will not be found", + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "foo", Type: "string", StringValue: "bar"}, + }, + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: "this is not a valid cel expression"}, + {Type: "groups/v1", Expression: "this is also not a valid cel expression"}, + {Type: "username/v1", Expression: "username"}, // valid + {Type: "policy/v1", Expression: "still not a valid cel expression"}, + }, + Examples: []v1alpha1.FederationDomainTransformsExample{ + { + Username: "does not matter because expressions did not compile", + }, + }, + }, + }, + { // this identity provider should be valid + DisplayName: "unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + }, + { + DisplayName: "not unique", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "this is the wrong kind", + Name: "also will not be found", + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "ryan", Type: "string", StringValue: "ryan"}, + {Name: "unused", Type: "stringList", StringListValue: []string{"foo", "bar"}}, + {Name: "rejectMe", Type: "string", StringValue: "rejectMeWithDefaultMessage"}, + }, + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + {Type: "policy/v1", Expression: `username == strConst.ryan || username == strConst.rejectMe`, Message: "only special users allowed"}, + {Type: "policy/v1", Expression: `username != "rejectMeWithDefaultMessage"`}, // no message specified + {Type: "username/v1", Expression: `"pre:" + username`}, + {Type: "groups/v1", Expression: `groups.map(g, "pre:" + g)`}, + }, + Examples: []v1alpha1.FederationDomainTransformsExample{ + { // this example should pass + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"pre:b", "pre:a", "pre:b", "pre:a"}, // order and repeats don't matter, treated like a set + Rejected: false, + }, + }, + { // this example should pass + Username: "other", + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only special users allowed", + }, + }, + { // this example should fail because it expects the user to be rejected but the user was actually not rejected + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "this input is ignored in this case", + }, + }, + { // this example should fail because it expects the user not to be rejected but they were actually rejected + Username: "other", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:other", + Groups: []string{"pre:a", "pre:b"}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong rejection message + Username: "other", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "wrong message", + }, + }, + { // this example should pass even though it does not make any assertion about the rejection message + // because the message assertions defaults to asserting the default rejection message + Username: "rejectMeWithDefaultMessage", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + }, + }, + { // this example should fail because it expects both the wrong username and groups + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "wrong", + Groups: []string{}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong username only + Username: "ryan", + Groups: []string{"a", "b"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "wrong", + Groups: []string{"pre:b", "pre:a"}, + Rejected: false, + }, + }, + { // this example should fail because it expects the wrong groups only + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"wrong2", "wrong1"}, + Rejected: false, + }, + }, + { // this example should fail because it does not expect anything but the auth actually was successful + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{}, + }, + }, + }, + }, + }, + }, v1alpha1.FederationDomainPhaseError) + + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulFederationDomainConditions(fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersDisplayNamesUnique", Status: "False", Reason: "DuplicateDisplayNames", + Message: `the names specified by .spec.identityProviders[].displayName contain duplicates: "not unique"`, + }, + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProvidersObjectRefsNotFound", + Message: here.Doc( + `cannot find resource specified by .spec.identityProviders[0].objectRef (with name "will not be found") + + cannot find resource specified by .spec.identityProviders[2].objectRef (with name "also will not be found")`, + )}, + { + Type: "IdentityProvidersObjectRefAPIGroupSuffixValid", Status: "False", Reason: "APIGroupUnrecognized", + Message: fmt.Sprintf(`some API groups specified by .spec.identityProviders[].objectRef.apiGroup are not recognized `+ + `(should be "idp.supervisor.%s"): "this is the wrong api group"`, env.APIGroupSuffix), + }, + { + Type: "IdentityProvidersObjectRefKindValid", Status: "False", Reason: "KindUnrecognized", + Message: `some kinds specified by .spec.identityProviders[].objectRef.kind are not recognized ` + + `(should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`, + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + { + Type: "TransformsExamplesPassed", Status: "False", Reason: "TransformsExamplesFailed", + Message: here.Doc( + `unable to check if the examples specified by .spec.identityProviders[0].transforms.examples[] had errors because an expression was invalid + + .spec.identityProviders[2].transforms.examples[2] example failed: + expected: authentication to be rejected + actual: authentication was not rejected + + .spec.identityProviders[2].transforms.examples[3] example failed: + expected: authentication not to be rejected + actual: authentication was rejected with message "only special users allowed" + + .spec.identityProviders[2].transforms.examples[4] example failed: + expected: authentication rejection message "wrong message" + actual: authentication rejection message "only special users allowed" + + .spec.identityProviders[2].transforms.examples[6] example failed: + expected: username "wrong" + actual: username "pre:ryan" + + .spec.identityProviders[2].transforms.examples[6] example failed: + expected: groups [] + actual: groups ["pre:a", "pre:b"] + + .spec.identityProviders[2].transforms.examples[7] example failed: + expected: username "wrong" + actual: username "pre:ryan" + + .spec.identityProviders[2].transforms.examples[8] example failed: + expected: groups ["wrong1", "wrong2"] + actual: groups ["pre:a", "pre:b"] + + .spec.identityProviders[2].transforms.examples[9] example failed: + expected: username "" + actual: username "pre:ryan" + + .spec.identityProviders[2].transforms.examples[9] example failed: + expected: groups [] + actual: groups ["pre:a", "pre:b"]`, + ), + }, + { + Type: "TransformsExpressionsValid", Status: "False", Reason: "InvalidTransformsExpressions", + Message: here.Doc( + `spec.identityProvider[0].transforms.expressions[0].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'is' expecting + | this is not a valid cel expression + | .....^ + + spec.identityProvider[0].transforms.expressions[1].expression was invalid: + CEL expression compile error: ERROR: :1:6: Syntax error: mismatched input 'is' expecting + | this is also not a valid cel expression + | .....^ + + spec.identityProvider[0].transforms.expressions[3].expression was invalid: + CEL expression compile error: ERROR: :1:7: Syntax error: mismatched input 'not' expecting + | still not a valid cel expression + | ......^`, + ), + }, + }, + )) + + // Updating the FederationDomain to fix some of the problems should make some of the errors go away. + err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + gotFD, err := federationDomainsClient.Get(ctx, fd.Name, metav1.GetOptions{}) + require.NoError(t, err) + + gotFD.Spec.IdentityProviders[0] = v1alpha1.FederationDomainIdentityProvider{ + // Fix the display name. + DisplayName: "now made unique", + // Fix the objectRef. + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "foo", Type: "string", StringValue: "bar"}, + }, + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + // Fix the compile errors. + {Type: "username/v1", Expression: `"pre:" + username`}, + }, + Examples: []v1alpha1.FederationDomainTransformsExample{ + { // this example should fail because it expects both the wrong username and groups + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "wrong", + Groups: []string{}, + Rejected: false, + }, + }, + }, + }, + } + + gotFD.Spec.IdentityProviders[2].Transforms.Examples = []v1alpha1.FederationDomainTransformsExample{ + { // this example should pass + Username: "other", + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Rejected: true, + Message: "only special users allowed", + }, + }, + } + + _, updateErr := federationDomainsClient.Update(ctx, gotFD, metav1.UpdateOptions{}) + return updateErr + }) + require.NoError(t, err) + + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, replaceSomeConditions( + allSuccessfulFederationDomainConditions(fd.Spec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "False", Reason: "IdentityProvidersObjectRefsNotFound", + Message: `cannot find resource specified by .spec.identityProviders[2].objectRef (with name "also will not be found")`, + }, + { + Type: "IdentityProvidersObjectRefKindValid", Status: "False", Reason: "KindUnrecognized", + Message: `some kinds specified by .spec.identityProviders[].objectRef.kind are not recognized ` + + `(should be one of "ActiveDirectoryIdentityProvider", "LDAPIdentityProvider", "OIDCIdentityProvider"): "this is the wrong kind"`, + }, + { + Type: "Ready", Status: "False", Reason: "NotReady", + Message: "the FederationDomain is not ready: see other conditions for details", + }, + { + Type: "TransformsExamplesPassed", Status: "False", Reason: "TransformsExamplesFailed", + Message: here.Doc( + `.spec.identityProviders[0].transforms.examples[0] example failed: + expected: username "wrong" + actual: username "pre:ryan" + + .spec.identityProviders[0].transforms.examples[0] example failed: + expected: groups [] + actual: groups ["a", "b"]`, + ), + }, + }, + )) + + // Updating the FederationDomain to fix the rest of the problems should make all the errors go away. + err = retry.RetryOnConflict(retry.DefaultRetry, func() error { + gotFD, err := federationDomainsClient.Get(ctx, fd.Name, metav1.GetOptions{}) + require.NoError(t, err) + + gotFD.Spec.IdentityProviders[2].ObjectRef = corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: oidcIdentityProvider.Name, + } + + gotFD.Spec.IdentityProviders[0].Transforms.Examples = []v1alpha1.FederationDomainTransformsExample{ + { // this example should pass + Username: "ryan", + Groups: []string{"b", "a"}, + Expects: v1alpha1.FederationDomainTransformsExampleExpects{ + Username: "pre:ryan", + Groups: []string{"a", "b"}, + }, + }, + } + + _, updateErr := federationDomainsClient.Update(ctx, gotFD, metav1.UpdateOptions{}) + return updateErr + }) + require.NoError(t, err) + + testlib.WaitForFederationDomainStatusPhase(ctx, t, fd.Name, v1alpha1.FederationDomainPhaseReady) + testlib.WaitForFederationDomainStatusConditions(ctx, t, fd.Name, allSuccessfulFederationDomainConditions(fd.Spec)) + }, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + tt.run(t) + }) + } +} + +func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) { + env := testlib.IntegrationEnv(t) + fdClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(env.SupervisorNamespace) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + + adminClient := testlib.NewKubernetesClientset(t) + usingOldKubeVersionInCluster := testutil.KubeServerMinorVersionInBetweenInclusive(t, adminClient.Discovery(), 0, 23) + usingReallyOldKubeVersionInCluster := testutil.KubeServerMinorVersionInBetweenInclusive(t, adminClient.Discovery(), 0, 19) + + objectMeta := testlib.ObjectMetaWithRandomName(t, "federation-domain") + + tests := []struct { + name string + fd *v1alpha1.FederationDomain + wantErr string + wantOldKubeErr string + wantReallyOldKubeErr string + }{ + { + name: "issuer cannot be empty", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "", + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.issuer: Invalid value: "": spec.issuer in body should be at least 1 chars long`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP display names cannot be empty", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].displayName: Invalid value: "": `+ + "spec.identityProviders[0].displayName in body should be at least 1 chars long", + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform constants must have unique names", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "notUnique", Type: "string", StringValue: "foo"}, + {Name: "notUnique", Type: "string", StringValue: "bar"}, + }, + }, + }, + }, + }, + }, + wantOldKubeErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.constants[1]: Duplicate value: map[string]interface {}{"name":"notUnique"}`, + env.APIGroupSuffix, objectMeta.Name), + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.constants[1]: Duplicate value: map[string]interface {}{"name":"notUnique"}`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform constant names cannot be empty", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "", Type: "string"}, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.constants[0].name: Invalid value: "": `+ + `spec.identityProviders[0].transforms.constants[0].name in body should be at least 1 chars long`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform constant names cannot be more than 64 characters", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "12345678901234567890123456789012345678901234567890123456789012345", Type: "string"}, + }, + }, + }, + }, + }, + }, + wantReallyOldKubeErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders.transforms.constants.name: Invalid value: "": `+ + `spec.identityProviders.transforms.constants.name in body should be at most 64 chars long`, + env.APIGroupSuffix, objectMeta.Name), + wantOldKubeErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders.transforms.constants.name: Invalid value: "12345678901234567890123456789012345678901234567890123456789012345": `+ + `spec.identityProviders.transforms.constants.name in body should be at most 64 chars long`, + env.APIGroupSuffix, objectMeta.Name), + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.constants[0].name: Too long: may not be longer than 64`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform constant names must be a legal CEL variable name", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "cannot have spaces", Type: "string"}, + {Name: "1mustStartWithLetter", Type: "string"}, + {Name: "_mustStartWithLetter", Type: "string"}, + {Name: "canOnlyIncludeLettersAndNumbersAnd_", Type: "string"}, + {Name: "CanStart1_withUpperCase", Type: "string"}, + }, + }, + }, + }, + }, + }, + wantReallyOldKubeErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders.transforms.constants.name: Invalid value: "": `+ + `spec.identityProviders.transforms.constants.name in body should match '^[a-zA-Z][_a-zA-Z0-9]*$'`, + env.APIGroupSuffix, objectMeta.Name), + wantOldKubeErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders.transforms.constants.name: Invalid value: "cannot have spaces": `+ + `spec.identityProviders.transforms.constants.name in body should match '^[a-zA-Z][_a-zA-Z0-9]*$'`, + env.APIGroupSuffix, objectMeta.Name), + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `[spec.identityProviders[0].transforms.constants[0].name: Invalid value: "cannot have spaces": `+ + `spec.identityProviders[0].transforms.constants[0].name in body should match '^[a-zA-Z][_a-zA-Z0-9]*$', `+ + `spec.identityProviders[0].transforms.constants[1].name: Invalid value: "1mustStartWithLetter": `+ + `spec.identityProviders[0].transforms.constants[1].name in body should match '^[a-zA-Z][_a-zA-Z0-9]*$', `+ + `spec.identityProviders[0].transforms.constants[2].name: Invalid value: "_mustStartWithLetter": `+ + `spec.identityProviders[0].transforms.constants[2].name in body should match '^[a-zA-Z][_a-zA-Z0-9]*$']`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform constant types must be one of the allowed enum strings", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "a", Type: "this is invalid"}, + {Name: "b", Type: "string"}, + {Name: "c", Type: "stringList"}, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.constants[0].type: Unsupported value: "this is invalid": `+ + `supported values: "string", "stringList"`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform expression types must be one of the allowed enum strings", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + {Type: "this is invalid", Expression: "foo"}, + {Type: "policy/v1", Expression: "foo"}, + {Type: "username/v1", Expression: "foo"}, + {Type: "groups/v1", Expression: "foo"}, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.expressions[0].type: Unsupported value: "this is invalid": `+ + `supported values: "policy/v1", "username/v1", "groups/v1"`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform expressions cannot be empty", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: ""}, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.expressions[0].expression: Invalid value: "": `+ + `spec.identityProviders[0].transforms.expressions[0].expression in body should be at least 1 chars long`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "IDP transform example usernames cannot be empty", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: objectMeta, + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Examples: []v1alpha1.FederationDomainTransformsExample{ + {Username: ""}, + {Username: "non-empty"}, + }, + }, + }, + }, + }, + }, + wantErr: fmt.Sprintf("FederationDomain.config.supervisor.%s %q is invalid: "+ + `spec.identityProviders[0].transforms.examples[0].username: Invalid value: "": `+ + `spec.identityProviders[0].transforms.examples[0].username in body should be at least 1 chars long`, + env.APIGroupSuffix, objectMeta.Name), + }, + { + name: "minimum valid", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "fd"), + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + }, + }, + }, + { + name: "minimum valid when IDPs are included", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "fd"), + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + }, + }, + }, + }, + }, + { + name: "minimum valid when IDP has transform constants, expressions, and examples", + fd: &v1alpha1.FederationDomain{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "fd"), + Spec: v1alpha1.FederationDomainSpec{ + Issuer: "https://example.com", + IdentityProviders: []v1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: "foo", + ObjectRef: corev1.TypedLocalObjectReference{ + APIGroup: ptr.To("required in older versions of Kubernetes for each item in the identityProviders slice"), + }, + Transforms: v1alpha1.FederationDomainTransforms{ + Constants: []v1alpha1.FederationDomainTransformsConstant{ + {Name: "foo", Type: "string"}, + }, + Expressions: []v1alpha1.FederationDomainTransformsExpression{ + {Type: "username/v1", Expression: "foo"}, + }, + Examples: []v1alpha1.FederationDomainTransformsExample{ + {Username: "foo"}, + }, + }, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, createErr := fdClient.Create(ctx, tt.fd, metav1.CreateOptions{}) + + t.Cleanup(func() { + // Delete it if it exists. + delErr := fdClient.Delete(ctx, tt.fd.Name, metav1.DeleteOptions{}) + if !k8serrors.IsNotFound(delErr) { + require.NoError(t, delErr) + } + }) + + if tt.wantErr == "" && tt.wantOldKubeErr == "" && tt.wantReallyOldKubeErr == "" { + require.NoError(t, createErr) + } else { + wantErr := tt.wantErr + if usingOldKubeVersionInCluster || usingReallyOldKubeVersionInCluster { + // Old versions of Kubernetes did not show the index where the error occurred in some of the messages, + // so remove the indices from the expected messages when running against an old version of Kube. + // For the above tests, it should be enough to assume that there will only be indices up to 10. + // This is useful when the only difference in the message between old and new is the missing indices. + // Otherwise, use wantOldKubeErr to say what the expected message should be for old versions. + for i := 0; i < 10; i++ { + wantErr = strings.ReplaceAll(wantErr, fmt.Sprintf("[%d]", i), "") + } + } + if usingOldKubeVersionInCluster && tt.wantOldKubeErr != "" { + // Sometimes there are other difference in older Kubernetes messages, so also allow exact + // expectation strings for those cases in wantOldKubeErr. When provided, use it on old Kube clusters. + wantErr = tt.wantOldKubeErr + } + if usingReallyOldKubeVersionInCluster && tt.wantReallyOldKubeErr != "" { + // Sometimes there are other difference in really old Kubernetes messages, so also allow exact + // expectation strings for those cases in wantOldKubeErr. When provided, use it on + // really old Kube clusters. + wantErr = tt.wantReallyOldKubeErr + } + require.EqualError(t, createErr, wantErr) + } + }) + } +} + +func replaceSomeConditions(conditions []metav1.Condition, replaceWithTheseConditions []metav1.Condition) []metav1.Condition { + cp := make([]metav1.Condition, len(conditions)) + copy(cp, conditions) + for _, replacementCond := range replaceWithTheseConditions { + for i, cond := range cp { + if replacementCond.Type == cond.Type { + cp[i] = replacementCond + break + } + } + } + return cp +} + +func allSuccessfulLegacyFederationDomainConditions(idpName string, federationDomainSpec v1alpha1.FederationDomainSpec) []metav1.Condition { + return replaceSomeConditions( + allSuccessfulFederationDomainConditions(federationDomainSpec), + []metav1.Condition{ + { + Type: "IdentityProvidersFound", Status: "True", Reason: "LegacyConfigurationSuccess", + Message: fmt.Sprintf(`no resources were specified by .spec.identityProviders[].objectRef but exactly one `+ + `identity provider resource has been found: using "%s" as identity provider: `+ + `please explicitly list identity providers in .spec.identityProviders `+ + `(this legacy configuration mode may be removed in a future version of Pinniped)`, idpName), + }, + }, + ) +} + +func allSuccessfulFederationDomainConditions(federationDomainSpec v1alpha1.FederationDomainSpec) []metav1.Condition { + return []metav1.Condition{ + { + Type: "IdentityProvidersDisplayNamesUnique", Status: "True", Reason: "Success", + Message: "the names specified by .spec.identityProviders[].displayName are unique", + }, + { + Type: "IdentityProvidersFound", Status: "True", Reason: "Success", + Message: "the resources specified by .spec.identityProviders[].objectRef were found", + }, + { + Type: "IdentityProvidersObjectRefAPIGroupSuffixValid", Status: "True", Reason: "Success", + Message: "the API groups specified by .spec.identityProviders[].objectRef.apiGroup are recognized", + }, + { + Type: "IdentityProvidersObjectRefKindValid", Status: "True", Reason: "Success", + Message: "the kinds specified by .spec.identityProviders[].objectRef.kind are recognized", + }, + { + Type: "IssuerIsUnique", Status: "True", Reason: "Success", + Message: "spec.issuer is unique among all FederationDomains", + }, + { + Type: "IssuerURLValid", Status: "True", Reason: "Success", + Message: "spec.issuer is a valid URL", + }, + { + Type: "OneTLSSecretPerIssuerHostname", Status: "True", Reason: "Success", + Message: "all FederationDomains are using the same TLS secret when using the same hostname in the spec.issuer URL", + }, + { + Type: "Ready", Status: "True", Reason: "Success", + Message: fmt.Sprintf("the FederationDomain is ready and its endpoints are available: "+ + "the discovery endpoint is %s/.well-known/openid-configuration", federationDomainSpec.Issuer), + }, + { + Type: "TransformsExamplesPassed", Status: "True", Reason: "Success", + Message: "the examples specified by .spec.identityProviders[].transforms.examples[] had no errors", + }, + { + Type: "TransformsExpressionsValid", Status: "True", Reason: "Success", + Message: "the expressions specified by .spec.identityProviders[].transforms.expressions[] are valid", + }, + } +} diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index ae58aead..0d4f2912 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -26,13 +26,15 @@ import ( "golang.org/x/oauth2" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "k8s.io/utils/strings/slices" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -160,13 +162,56 @@ func TestSupervisorLogin_Browser(t *testing.T) { return ldapIDP, secret } + // The downstream ID token Subject should include the upstream user ID after the upstream issuer name + // and IDP display name. + expectedIDTokenSubjectRegexForUpstreamOIDC := "^" + + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?idpName=test-upstream-oidc-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub=") + ".+" + + "$" + + // The downstream ID token Subject should be the Host URL, plus the user search base, plus the IDP display name, + // plus value pulled from the requested UserSearch.Attributes.UID attribute. + expectedIDTokenSubjectRegexForUpstreamLDAP := "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamLDAP.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)) + + regexp.QuoteMeta("&idpName=test-upstream-ldap-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue))) + + "$" + + // Some of the LDAP tests below use the StartTLS server and port. + expectedIDTokenSubjectRegexForUpstreamLDAPStartTLSHost := "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamLDAP.StartTLSOnlyHost+"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)) + + regexp.QuoteMeta("&idpName=test-upstream-ldap-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue))) + + "$" + + // The downstream ID token Subject should be in the the same format as LDAP above, but with AD-specific values. + expectedIDTokenSubjectRegexForUpstreamAD := "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)) + + regexp.QuoteMeta("&idpName=test-upstream-ad-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue) + + "$" + + // Sometimes the AD tests below use a different search base. + expectedIDTokenSubjectRegexForUpstreamADWithCustomUserSearchBase := "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.UserSearchBase)) + + regexp.QuoteMeta("&idpName=test-upstream-ad-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue) + + "$" + + // Sometimes the AD tests below create a user dynamically so we can't know the UID up front. + expectedIDTokenSubjectRegexForUpstreamADWithUnkownUID := "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)) + + regexp.QuoteMeta("&idpName=test-upstream-ad-idp-") + `[\w]+` + + regexp.QuoteMeta("&sub=") + ".+" + + "$" + // These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases. // They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an // OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go. // // Each of these tests perform the following flow: - // 1. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available. - // 2. Configure an IDP CR. + // 1. Configure an IDP CR. + // 2. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available. // 3. Call the authorization endpoint and log in as a specific user. // Note that these tests do not use form_post response type (which is tested by e2e_test.go). // 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure. @@ -189,6 +234,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { // its cleanup. Return the name of the IDP CR. createIDP func(t *testing.T) string + // Optionally specify the identityProviders part of the FederationDomain's spec by returning it from this function. + // Also return the displayName of the IDP that should be used during authentication. + // This function takes the name of the IDP CR which was returned by createIDP() as as argument. + federationDomainIDPs func(t *testing.T, idpName string) (idps []configv1alpha1.FederationDomainIdentityProvider, useIDPDisplayName string) + // Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the // test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret. // When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693 @@ -268,8 +318,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { pinnipedSessionData := pinnipedSession.Custom pinnipedSessionData.OIDC.UpstreamIssuer = "wrong-issuer" }, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, @@ -292,15 +341,17 @@ func TestSupervisorLogin_Browser(t *testing.T) { customSessionData := pinnipedSession.Custom customSessionData.Username = "some-incorrect-username" }, - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { - // even if we update this group to the wrong thing, we expect that it will return to the correct - // value after we refresh. + // Even if we update the groups to some names that did not come from the OIDC server, + // we expect that it will revert to the real groups from the OIDC server after we refresh. // However if there are no expected groups then they will not update, so we should skip this. if len(env.SupervisorUpstreamOIDC.ExpectedGroups) > 0 { - sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"} + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session } return env.SupervisorUpstreamOIDC.ExpectedGroups }, @@ -337,7 +388,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { customSessionData := pinnipedSession.Custom customSessionData.Username = "some-incorrect-username" }, - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, @@ -366,8 +417,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.OIDC.UpstreamRefreshToken) customSessionData.OIDC.UpstreamRefreshToken = "invalid-updated-refresh-token" }, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, }, @@ -397,8 +447,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { false, ) }, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, wantDownstreamIDTokenAdditionalClaims: wantGroupsInAdditionalClaimsIfGroupsExist(map[string]interface{}{ @@ -422,9 +471,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { } return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, wantDownstreamIDTokenAdditionalClaims: wantGroupsInAdditionalClaimsIfGroupsExist(map[string]interface{}{ @@ -449,9 +497,11 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { - // even if we update this group to the wrong thing, we expect that it will return to the correct - // value after we refresh. - sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"} + // Even if we update this group to the some names that did not come from the LDAP server, + // we expect that it will return to the real groups from the LDAP server after we refresh. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session return env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs }, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { @@ -461,12 +511,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Subject = "not-right" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -493,12 +538,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { false, ) }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -523,12 +563,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { false, ) }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -552,7 +587,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access"}, wantDownstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username", "groups"}, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, @@ -568,13 +603,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -623,13 +653,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAPWithBadCredentialsAndThenGoodCredentials, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -655,11 +680,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { - // update the list of groups to the wrong thing and see that they do not get updated because - // skip group refresh is set - wrongGroups := []string{"some-wrong-group", "some-other-group"} - sessionData.Fosite.Claims.Extra["groups"] = wrongGroups - return wrongGroups + // Update the list of groups to some groups that would not come from the real LDAP queries, + // and see that these become the user's new groups after refresh, because LDAP skip group refresh is set. + // Since we are skipping the LDAP group query during refresh, the refresh should use what the session + // says is the original list of untransformed groups from the initial login, and then perform the + // transformations on them again. However, since there are no transformations configured, they will not + // be changed by any transformations in this case. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} // these groups are not in LDAP server + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + return initialGroupMembership // these are the expected groups after the refresh is performed }, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { customSessionData := pinnipedSession.Custom @@ -668,12 +698,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Subject = "not-right" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -708,9 +733,12 @@ func TestSupervisorLogin_Browser(t *testing.T) { ) }, editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { - // even if we update this group to the wrong thing, we expect that it will return to the correct - // value (no groups) after we refresh. - sessionData.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"} + // Even if we update this group to the some names that did not come from the LDAP server, + // we expect that it will return to the real groups from the LDAP server after we refresh, + // which in this case is no groups since this test uses a group search base which results in no groups. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session return []string{} }, breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { @@ -720,12 +748,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Subject = "not-right" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -759,12 +782,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.LDAP.UserDN) customSessionData.Username = "not-the-same" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.StartTLSOnlyHost+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAPStartTLSHost, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserDN) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsCNs, @@ -833,12 +851,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.LDAP.UserDN) customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -904,12 +917,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.LDAP.UserDN) customSessionData.LDAP.UserDN = "cn=not-a-user,dc=pinniped,dc=dev" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -938,12 +946,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) customSessionData.Username = "not-the-same" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamAD, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" @@ -988,12 +991,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { fositeSessionData := pinnipedSession.Fosite fositeSessionData.Claims.Subject = "not-right" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.UserSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamADWithCustomUserSearchBase, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$" @@ -1036,12 +1034,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { false, ) }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.UserSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamADWithCustomUserSearchBase, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserMailAttributeValue) + "$" @@ -1093,12 +1086,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamAD, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" @@ -1165,12 +1153,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) customSessionData.ActiveDirectory.UserDN = "cn=not-a-user,dc=pinniped,dc=dev" }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamAD, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" @@ -1199,8 +1182,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { testlib.ChangeADTestUserPassword(t, env, username) }, - // we can't know the subject ahead of time because we created a new user and don't know their uid, - // so skip wantDownstreamIDTokenSubjectToMatch + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamADWithUnkownUID, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(username string) string { return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" @@ -1229,8 +1211,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { testlib.DeactivateADTestUser(t, env, username) }, - // we can't know the subject ahead of time because we created a new user and don't know their uid, - // so skip wantDownstreamIDTokenSubjectToMatch + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamADWithUnkownUID, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(username string) string { return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" @@ -1259,8 +1240,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { breakRefreshSessionData: func(t *testing.T, sessionData *psession.PinnipedSession, _, username string) { testlib.LockADTestUser(t, env, username) }, - // we can't know the subject ahead of time because we created a new user and don't know their uid, - // so skip wantDownstreamIDTokenSubjectToMatch + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamADWithUnkownUID, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(username string) string { return "^" + regexp.QuoteMeta(username+"@"+env.SupervisorUpstreamActiveDirectory.Domain) + "$" @@ -1318,12 +1298,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { require.NoError(t, err) time.Sleep(10 * time.Second) // wait for controllers to pick up the change }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -1362,12 +1337,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { time.Sleep(10 * time.Second) // wait for controllers to pick up the change return []string{} }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -1380,10 +1350,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { createIDP: func(t *testing.T) string { return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - requestTokenExchangeAud: "contains-disallowed-substring.pinniped.dev-something", // .pinniped.dev substring is not allowed - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + requestTokenExchangeAud: "contains-disallowed-substring.pinniped.dev-something", // .pinniped.dev substring is not allowed + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, wantTokenExchangeResponse: func(t *testing.T, status int, body string) { @@ -1401,10 +1370,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { createIDP: func(t *testing.T) string { return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - requestTokenExchangeAud: "client.oauth.pinniped.dev-client-name", // OIDC dynamic client name is not allowed - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + requestTokenExchangeAud: "client.oauth.pinniped.dev-client-name", // OIDC dynamic client name is not allowed + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, wantTokenExchangeResponse: func(t *testing.T, status int, body string) { @@ -1422,10 +1390,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { createIDP: func(t *testing.T) string { return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - requestTokenExchangeAud: "pinniped-cli", // pinniped-cli is not allowed - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + requestTokenExchangeAud: "pinniped-cli", // pinniped-cli is not allowed + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, // the ID token Username should include the upstream user ID after the upstream issuer name wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, wantTokenExchangeResponse: func(t *testing.T, status int, body string) { @@ -1456,11 +1423,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, }, @@ -1489,11 +1455,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, - // the ID token Subject should include the upstream user ID after the upstream issuer name - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, wantDownstreamIDTokenAdditionalClaims: wantGroupsInAdditionalClaimsIfGroupsExist(map[string]interface{}{ @@ -1513,20 +1478,15 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -1545,7 +1505,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange grant type not allowed AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, // a validation requires that we also disallow the pinniped:request-audience scope - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test @@ -1579,7 +1539,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test @@ -1613,7 +1573,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"}, // username not allowed - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test @@ -1639,7 +1599,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // token exchange not allowed (required to exclude groups scope) AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"}, // groups not allowed - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test @@ -1665,21 +1625,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username"}, // do not request (or expect) groups - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "username"}, // do not request (or expect) groups + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" @@ -1698,21 +1653,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "groups"}, // do not request (or expect) username - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + downstreamScopes: []string{"openid", "pinniped:request-audience", "offline_access", "groups"}, // do not request (or expect) username + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "" // username should not exist as a claim since we did not request it }, @@ -1737,21 +1687,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"}, // validations require that when username/groups are excluded, then token exchange must also not be allowed - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, - downstreamScopes: []string{"openid", "offline_access"}, // do not request (or expect) pinniped:request-audience or username or groups - requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamLDAP.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ - "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), - ) + "$", + downstreamScopes: []string{"openid", "offline_access"}, // do not request (or expect) pinniped:request-audience or username or groups + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamLDAP, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "" // username should not exist as a claim since we did not request it }, @@ -1776,24 +1721,15 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) }, - requestAuthorization: func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { - requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t, - downstreamIssuer, - downstreamAuthorizeURL, - downstreamCallbackURL, - env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login - env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login - httpClient, - ) + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login + env.SupervisorUpstreamActiveDirectory.TestUserPassword // password to present to server during login }, - // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute - wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( - "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ - "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+ - "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, - ) + "$", + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamAD, // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" @@ -1812,7 +1748,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - }, configv1alpha1.PhaseReady) + }, configv1alpha1.OIDCClientPhaseReady) return clientID, "wrong-client-secret" }, testUser: func(t *testing.T) (string, string) { @@ -1823,6 +1759,340 @@ func TestSupervisorLogin_Browser(t *testing.T) { requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, wantAuthcodeExchangeError: `oauth2: "invalid_client" "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)."`, }, + { + name: "oidc browser flow with custom username and groups claim settings with identity transformations", + maybeSkip: skipNever, + createIDP: func(t *testing.T) string { + spec := basicOIDCIdentityProviderSpec() + spec.Claims = idpv1alpha1.OIDCClaims{ + Username: env.SupervisorUpstreamOIDC.UsernameClaim, + Groups: env.SupervisorUpstreamOIDC.GroupsClaim, + } + spec.AuthorizationConfig = idpv1alpha1.OIDCAuthorizationConfig{ + AdditionalScopes: env.SupervisorUpstreamOIDC.AdditionalScopes, + } + return testlib.CreateTestOIDCIdentityProvider(t, spec, idpv1alpha1.PhaseReady).Name + }, + federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) { + displayName := "my oidc idp" + return []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: displayName, + ObjectRef: v1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "OIDCIdentityProvider", + Name: idpName, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "username/v1", + Expression: fmt.Sprintf(`username == "%s" ? "username-prefix:" + username : username`, + env.SupervisorUpstreamOIDC.Username), + }, + { + Type: "groups/v1", + Expression: `groups.map(g, "group-prefix:" + g)`, + }, + }, + }, + }, + }, displayName + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + wantDownstreamIDTokenSubjectToMatch: "^" + + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer) + + regexp.QuoteMeta("?idpName="+url.QueryEscape("my oidc idp")) + + regexp.QuoteMeta("&sub=") + ".+" + + "$", + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta("username-prefix:"+env.SupervisorUpstreamOIDC.Username) + "$" + }, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamOIDC.ExpectedGroups), + editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { + // Even if we update the groups to some names that did not come from the OIDC server, + // we expect that it will revert to the real groups from the OIDC server after we refresh. + // However if there are no expected groups then they will not update, so we should skip this. + if len(env.SupervisorUpstreamOIDC.ExpectedGroups) > 0 { + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + } + return testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamOIDC.ExpectedGroups) + }, + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { + customSessionData := pinnipedSession.Custom + // Simulate what happens when the transformations choose a different downstream username during refresh + // compared to what they chose during the initial login, by changing what they decided during the initial login. + customSessionData.Username = "some-different-downstream-username" + }, + }, + { + name: "ldap CLI flow with email as username and groups names as DNs and using an LDAP provider which supports TLS with identity transformations", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) { + displayName := "my ldap idp" + return []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: displayName, + ObjectRef: v1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "LDAPIdentityProvider", + Name: idpName, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "username/v1", + Expression: fmt.Sprintf(`username == "%s" ? "username-prefix:" + username : username`, + env.SupervisorUpstreamLDAP.TestUserMailAttributeValue), + }, + { + Type: "groups/v1", + Expression: `groups.map(g, "group-prefix:" + g)`, + }, + }, + }, + }, + }, displayName + }, + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { + // Even if we update this group to the some names that did not come from the LDAP server, + // we expect that it will return to the real groups from the LDAP server after we refresh. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + return testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs) + }, + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { + customSessionData := pinnipedSession.Custom + // Simulate what happens when the transformations choose a different downstream username during refresh + // compared to what they chose during the initial login, by changing what they decided during the initial login. + customSessionData.Username = "some-different-downstream-username" + }, + wantDownstreamIDTokenSubjectToMatch: "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamLDAP.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)) + + regexp.QuoteMeta("&idpName="+url.QueryEscape("my ldap idp")) + + regexp.QuoteMeta("&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue))) + + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute and then transformed + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta("username-prefix:"+env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs), + }, + { + name: "ldap browser flow with email as username and groups names as DNs and using an LDAP provider which supports TLS with identity transformations", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) { + displayName := "my ldap idp" + return []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: displayName, + ObjectRef: v1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "LDAPIdentityProvider", + Name: idpName, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "username/v1", + Expression: fmt.Sprintf(`username == "%s" ? "username-prefix:" + username : username`, + env.SupervisorUpstreamLDAP.TestUserMailAttributeValue), + }, + { + Type: "groups/v1", + Expression: `groups.map(g, "group-prefix:" + g)`, + }, + }, + }, + }, + }, displayName + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { + // Even if we update this group to the some names that did not come from the LDAP server, + // we expect that it will return to the real groups from the LDAP server after we refresh. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + return testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs) + }, + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { + customSessionData := pinnipedSession.Custom + // Simulate what happens when the transformations choose a different downstream username during refresh + // compared to what they chose during the initial login, by changing what they decided during the initial login. + customSessionData.Username = "some-different-downstream-username" + }, + wantDownstreamIDTokenSubjectToMatch: "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamLDAP.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)) + + regexp.QuoteMeta("&idpName="+url.QueryEscape("my ldap idp")) + + regexp.QuoteMeta("&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue))) + + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute and then transformed + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta("username-prefix:"+env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs), + }, + { + name: "active directory CLI flow with all default options with identity transformations", + maybeSkip: skipActiveDirectoryTests, + createIDP: func(t *testing.T) string { + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name + }, + federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) { + displayName := "my ad idp" + return []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: displayName, + ObjectRef: v1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "ActiveDirectoryIdentityProvider", + Name: idpName, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "username/v1", + Expression: fmt.Sprintf(`username == "%s" ? "username-prefix:" + username : username`, + env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue), + }, + { + Type: "groups/v1", + Expression: `groups.map(g, "group-prefix:" + g)`, + }, + }, + }, + }, + }, displayName + }, + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login + env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { + // Even if we update this group to the some names that did not come from the AD server, + // we expect that it will return to the real groups from the AD server after we refresh. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + return testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames) + }, + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { + customSessionData := pinnipedSession.Custom + require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) + require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) + // Simulate what happens when the transformations choose a different downstream username during refresh + // compared to what they chose during the initial login, by changing what they decided during the initial login. + customSessionData.Username = "some-different-downstream-username" + }, + wantDownstreamIDTokenSubjectToMatch: "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)) + + regexp.QuoteMeta("&idpName="+url.QueryEscape("my ad idp")) + + regexp.QuoteMeta("&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue) + + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta("username-prefix:"+env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames), + }, + { + name: "active directory browser flow with all default options with identity transformations", + maybeSkip: skipActiveDirectoryTests, + createIDP: func(t *testing.T) string { + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name + }, + federationDomainIDPs: func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string) { + displayName := "my ad idp" + return []configv1alpha1.FederationDomainIdentityProvider{ + { + DisplayName: displayName, + ObjectRef: v1.TypedLocalObjectReference{ + APIGroup: ptr.To("idp.supervisor." + env.APIGroupSuffix), + Kind: "ActiveDirectoryIdentityProvider", + Name: idpName, + }, + Transforms: configv1alpha1.FederationDomainTransforms{ + Expressions: []configv1alpha1.FederationDomainTransformsExpression{ + { + Type: "username/v1", + Expression: fmt.Sprintf(`username == "%s" ? "username-prefix:" + username : username`, + env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue), + }, + { + Type: "groups/v1", + Expression: `groups.map(g, "group-prefix:" + g)`, + }, + }, + }, + }, + }, displayName + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login + env.SupervisorUpstreamActiveDirectory.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + editRefreshSessionDataWithoutBreaking: func(t *testing.T, sessionData *psession.PinnipedSession, _, _ string) []string { + // Even if we update this group to the some names that did not come from the AD server, + // we expect that it will return to the real groups from the AD server after we refresh. + initialGroupMembership := []string{"some-wrong-group", "some-other-group"} + sessionData.Custom.UpstreamGroups = initialGroupMembership // upstream group names in session + sessionData.Fosite.Claims.Extra["groups"] = initialGroupMembership // downstream group names in session + return testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames) + }, + breakRefreshSessionData: func(t *testing.T, pinnipedSession *psession.PinnipedSession, _, _ string) { + customSessionData := pinnipedSession.Custom + require.Equal(t, psession.ProviderTypeActiveDirectory, customSessionData.ProviderType) + require.NotEmpty(t, customSessionData.ActiveDirectory.UserDN) + // Simulate what happens when the transformations choose a different downstream username during refresh + // compared to what they chose during the initial login, by changing what they decided during the initial login. + customSessionData.Username = "some-different-downstream-username" + }, + wantDownstreamIDTokenSubjectToMatch: "^" + + regexp.QuoteMeta("ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+"?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)) + + regexp.QuoteMeta("&idpName="+url.QueryEscape("my ad idp")) + + regexp.QuoteMeta("&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue) + + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta("username-prefix:"+env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: testutil.AddPrefixToEach("group-prefix:", env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames), + }, } for _, test := range tests { @@ -1833,6 +2103,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { testSupervisorLogin( t, tt.createIDP, + tt.federationDomainIDPs, tt.requestAuthorization, tt.editRefreshSessionDataWithoutBreaking, tt.breakRefreshSessionData, @@ -1987,6 +2258,7 @@ func requireEventuallySuccessfulActiveDirectoryIdentityProviderConditions(t *tes func testSupervisorLogin( t *testing.T, createIDP func(t *testing.T) string, + federationDomainIDPs func(t *testing.T, idpName string) ([]configv1alpha1.FederationDomainIdentityProvider, string), requestAuthorization func(t *testing.T, downstreamIssuer string, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client), editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string) []string, breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string), @@ -2063,11 +2335,26 @@ func testSupervisorLogin( map[string]string{"tls.crt": string(certPEM), "tls.key": string(keyPEM)}, ) - // Create the downstream FederationDomain and expect it to go into the success status condition. + // Create upstream IDP and wait for it to become ready. + idpName := createIDP(t) + + // Determine if and how we should set spec.identityProviders for the FederationDomain. + var fdIDPSpec []configv1alpha1.FederationDomainIdentityProvider + useIDPDisplayName := "" + if federationDomainIDPs != nil { + fdIDPSpec, useIDPDisplayName = federationDomainIDPs(t, idpName) + } + + // Create the downstream FederationDomain and expect it to go into the appropriate status condition. downstream := testlib.CreateTestFederationDomain(ctx, t, - issuerURL.String(), - certSecret.Name, - configv1alpha1.SuccessFederationDomainStatusCondition, + configv1alpha1.FederationDomainSpec{ + Issuer: issuerURL.String(), + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: certSecret.Name}, + IdentityProviders: fdIDPSpec, + }, + // The IDP CR already exists, so even for legacy FederationDomains which do not explicitly list + // the IDPs in the spec, the FederationDomain should be ready. + configv1alpha1.FederationDomainPhaseReady, ) // Ensure the the JWKS data is created and ready for the new FederationDomain by waiting for @@ -2088,9 +2375,6 @@ func testSupervisorLogin( requireEventually.Equal(http.StatusOK, rsp.StatusCode) }, 30*time.Second, 200*time.Millisecond) - // Create upstream IDP and wait for it to become ready. - idpName := createIDP(t) - // Start a callback server on localhost. localCallbackServer := startLocalCallbackServer(t) @@ -2151,12 +2435,15 @@ func testSupervisorLogin( require.NoError(t, err) pkceParam, err := pkce.Generate() require.NoError(t, err) - downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL( - stateParam.String(), + authorizeRequestParams := []oauth2.AuthCodeOption{ nonceParam.Param(), pkceParam.Challenge(), pkceParam.Method(), - ) + } + if useIDPDisplayName != "" { + authorizeRequestParams = append(authorizeRequestParams, oauth2.SetAuthURLParam("pinniped_idp_name", useIDPDisplayName)) + } + downstreamAuthorizeURL := downstreamOAuth2Config.AuthCodeURL(stateParam.String(), authorizeRequestParams...) // Perform parameterized auth code acquisition. requestAuthorization(t, downstream.Spec.Issuer, downstreamAuthorizeURL, localCallbackServer.URL, username, password, httpClient) @@ -2240,7 +2527,7 @@ func testSupervisorLogin( // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + oauthStore := storage.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) @@ -2302,7 +2589,7 @@ func testSupervisorLogin( // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + oauthStore := storage.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) @@ -2526,7 +2813,16 @@ func makeAuthorizationRequestAndRequireSecurityHeaders(ctx context.Context, t *t require.NoError(t, err) authorizeResp, err := httpClient.Do(authorizeRequest) require.NoError(t, err) + body, err := io.ReadAll(authorizeResp.Body) + require.NoError(t, err) require.NoError(t, authorizeResp.Body.Close()) + if authorizeResp.StatusCode >= 400 { + // The request should not have failed, so print the response for debugging purposes. + t.Logf("makeAuthorizationRequestAndRequireSecurityHeaders authorization response: %#v", authorizeResp) + t.Logf("makeAuthorizationRequestAndRequireSecurityHeaders authorization response body: %q", body) + } + require.Less(t, authorizeResp.StatusCode, 400, + "expected a successful authorize response, but got a response status indicating an error (see log above)") expectSecurityHeaders(t, authorizeResp, false) } diff --git a/test/integration/supervisor_secrets_test.go b/test/integration/supervisor_secrets_test.go index 51b1977c..66321f59 100644 --- a/test/integration/supervisor_secrets_test.go +++ b/test/integration/supervisor_secrets_test.go @@ -6,6 +6,7 @@ package integration import ( "context" "encoding/json" + "fmt" "testing" "time" @@ -28,7 +29,12 @@ func TestSupervisorSecrets_Parallel(t *testing.T) { defer cancel() // Create our FederationDomain under test. - federationDomain := testlib.CreateTestFederationDomain(ctx, t, "", "", "") + federationDomain := testlib.CreateTestFederationDomain(ctx, t, + configv1alpha1.FederationDomainSpec{ + Issuer: fmt.Sprintf("http://test-issuer-%s.pinniped.dev", testlib.RandHex(t, 8)), + }, + configv1alpha1.FederationDomainPhaseError, // in phase error until there is an IDP created, but this test does not care + ) tests := []struct { name string diff --git a/test/integration/supervisor_storage_test.go b/test/integration/supervisor_storage_test.go index e5832262..458d5209 100644 --- a/test/integration/supervisor_storage_test.go +++ b/test/integration/supervisor_storage_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -17,8 +17,8 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "go.pinniped.dev/internal/federationdomain/clientregistry" "go.pinniped.dev/internal/fositestorage/authorizationcode" - "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/test/testlib" ) @@ -91,7 +91,7 @@ func TestAuthorizeCodeStorage(t *testing.T) { // Note that CreateAuthorizeCodeSession() sets Active to true and also sets the Version before storing the session, // so expect those here. session.Active = true - session.Version = "4" // this is the value of the authorizationcode.authorizeCodeStorageVersion constant + session.Version = "5" // this is the value of the authorizationcode.authorizeCodeStorageVersion constant expectedSessionStorageJSON, err := json.Marshal(session) require.NoError(t, err) require.JSONEq(t, string(expectedSessionStorageJSON), string(initialSecret.Data["pinniped-storage-data"])) diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index f84b5358..3327bd4a 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -28,8 +28,9 @@ import ( configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" - "go.pinniped.dev/internal/oidc" - "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/oidc" + "go.pinniped.dev/internal/federationdomain/oidcclientvalidator" + "go.pinniped.dev/internal/federationdomain/storage" "go.pinniped.dev/internal/psession" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/filesession" @@ -82,9 +83,11 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // Create the downstream FederationDomain and expect it to go into the success status condition. downstream := testlib.CreateTestFederationDomain(ctx, t, - issuerURL.String(), - certSecret.Name, - configv1alpha1.SuccessFederationDomainStatusCondition, + configv1alpha1.FederationDomainSpec{ + Issuer: issuerURL.String(), + TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: certSecret.Name}, + }, + configv1alpha1.FederationDomainPhaseError, // in phase error until there is an IDP created ) // Create a JWTAuthenticator that will validate the tokens from the downstream issuer. @@ -105,7 +108,8 @@ func TestSupervisorWarnings_Browser(t *testing.T) { expectedUsername := env.SupervisorUpstreamLDAP.TestUserMailAttributeValue - setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + createdProvider := setupClusterForEndToEndLDAPTest(t, expectedUsername, env) + testlib.WaitForFederationDomainStatusPhase(ctx, t, downstream.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/ldap-test-refresh-sessions.yaml" @@ -173,10 +177,11 @@ func TestSupervisorWarnings_Browser(t *testing.T) { downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"} sort.Strings(downstreamScopes) sessionCacheKey := oidcclient.SessionCacheKey{ - Issuer: downstream.Spec.Issuer, - ClientID: "pinniped-cli", - Scopes: downstreamScopes, - RedirectURI: "http://localhost:0/callback", + Issuer: downstream.Spec.Issuer, + ClientID: "pinniped-cli", + Scopes: downstreamScopes, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: createdProvider.Name, } // use it to get the cache entry token := cache.GetToken(sessionCacheKey) @@ -186,7 +191,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // out of kube secret storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + oauthStore := storage.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) @@ -194,7 +199,8 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // change the groups to simulate them changing in the IDP. pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) require.True(t, ok, "should have been able to cast session data to PinnipedSession") - pinnipedSession.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"} + pinnipedSession.Custom.UpstreamGroups = []string{"some-wrong-group", "some-other-group"} // update upstream groups + pinnipedSession.Fosite.Claims.Extra["groups"] = []string{"some-wrong-group", "some-other-group"} // update downstream groups require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, refreshTokenSignature)) require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, refreshTokenSignature, storedRefreshSession)) @@ -248,6 +254,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { sAMAccountName := expectedUsername + "@" + env.SupervisorUpstreamActiveDirectory.Domain setupClusterForEndToEndActiveDirectoryTest(t, sAMAccountName, env) + testlib.WaitForFederationDomainStatusPhase(ctx, t, downstream.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/ldap-test-refresh-sessions.yaml" @@ -371,7 +378,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { }) // Create upstream OIDC provider and wait for it to become ready. - testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ + createdProvider := testlib.CreateTestOIDCIdentityProvider(t, idpv1alpha1.OIDCIdentityProviderSpec{ Issuer: env.SupervisorUpstreamOIDC.Issuer, TLS: &idpv1alpha1.TLSSpec{ CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)), @@ -387,6 +394,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { SecretName: testlib.CreateClientCredsSecret(t, env.SupervisorUpstreamOIDC.ClientID, env.SupervisorUpstreamOIDC.ClientSecret).Name, }, }, idpv1alpha1.PhaseReady) + testlib.WaitForFederationDomainStatusPhase(ctx, t, downstream.Name, configv1alpha1.FederationDomainPhaseReady) // Use a specific session cache for this test. sessionCachePath := tempDir + "/ldap-test-refresh-sessions.yaml" @@ -481,10 +489,11 @@ func TestSupervisorWarnings_Browser(t *testing.T) { downstreamScopes := []string{"offline_access", "openid", "pinniped:request-audience", "groups"} sort.Strings(downstreamScopes) sessionCacheKey := oidcclient.SessionCacheKey{ - Issuer: downstream.Spec.Issuer, - ClientID: "pinniped-cli", - Scopes: downstreamScopes, - RedirectURI: "http://localhost:0/callback", + Issuer: downstream.Spec.Issuer, + ClientID: "pinniped-cli", + Scopes: downstreamScopes, + RedirectURI: "http://localhost:0/callback", + UpstreamProviderName: createdProvider.Name, } // use it to get the cache entry token := cache.GetToken(sessionCacheKey) @@ -494,7 +503,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // out of kube secret storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + oauthStore := storage.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) diff --git a/test/testlib/client.go b/test/testlib/client.go index 55f208c1..f8e33970 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -267,29 +267,25 @@ func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T, spec auth1alp } } -// CreateTestFederationDomain creates and returns a test FederationDomain in +// CreateTestFederationDomain creates and returns a test FederationDomain in the // $PINNIPED_TEST_SUPERVISOR_NAMESPACE, which will be automatically deleted at the end of the // current test's lifetime. -// If the provided issuer is not the empty string, then it will be used for the -// FederationDomain.Spec.Issuer field. Else, a random issuer will be generated. -func CreateTestFederationDomain(ctx context.Context, t *testing.T, issuer string, certSecretName string, expectStatus configv1alpha1.FederationDomainStatusCondition) *configv1alpha1.FederationDomain { +func CreateTestFederationDomain( + ctx context.Context, + t *testing.T, + spec configv1alpha1.FederationDomainSpec, + expectStatus configv1alpha1.FederationDomainPhase, +) *configv1alpha1.FederationDomain { t.Helper() testEnv := IntegrationEnv(t) createContext, cancel := context.WithTimeout(ctx, time.Minute) defer cancel() - if issuer == "" { - issuer = fmt.Sprintf("http://test-issuer-%s.pinniped.dev", RandHex(t, 8)) - } - - federationDomains := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace) - federationDomain, err := federationDomains.Create(createContext, &configv1alpha1.FederationDomain{ + federationDomainsClient := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace) + federationDomain, err := federationDomainsClient.Create(createContext, &configv1alpha1.FederationDomain{ ObjectMeta: testObjectMeta(t, "oidc-provider"), - Spec: configv1alpha1.FederationDomainSpec{ - Issuer: issuer, - TLS: &configv1alpha1.FederationDomainTLSSpec{SecretName: certSecretName}, - }, + Spec: spec, }, metav1.CreateOptions{}) require.NoError(t, err, "could not create test FederationDomain") t.Logf("created test FederationDomain %s/%s", federationDomain.Namespace, federationDomain.Name) @@ -299,7 +295,7 @@ func CreateTestFederationDomain(ctx context.Context, t *testing.T, issuer string t.Logf("cleaning up test FederationDomain %s/%s", federationDomain.Namespace, federationDomain.Name) deleteCtx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - err := federationDomains.Delete(deleteCtx, federationDomain.Name, metav1.DeleteOptions{}) + err := federationDomainsClient.Delete(deleteCtx, federationDomain.Name, metav1.DeleteOptions{}) notFound := k8serrors.IsNotFound(err) // It's okay if it is not found, because it might have been deleted by another part of this test. if !notFound { @@ -307,28 +303,58 @@ func CreateTestFederationDomain(ctx context.Context, t *testing.T, issuer string } }) - // If we're not expecting any particular status, just return the new FederationDomain immediately. - if expectStatus == "" { - return federationDomain - } - // Wait for the FederationDomain to enter the expected phase (or time out). - var result *configv1alpha1.FederationDomain + WaitForFederationDomainStatusPhase(ctx, t, federationDomain.Name, expectStatus) + + return federationDomain +} + +func WaitForFederationDomainStatusPhase(ctx context.Context, t *testing.T, federationDomainName string, expectPhase configv1alpha1.FederationDomainPhase) { + t.Helper() + testEnv := IntegrationEnv(t) + federationDomainsClient := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace) + RequireEventuallyf(t, func(requireEventually *require.Assertions) { - var err error - result, err = federationDomains.Get(ctx, federationDomain.Name, metav1.GetOptions{}) + fd, err := federationDomainsClient.Get(ctx, federationDomainName, metav1.GetOptions{}) requireEventually.NoError(err) - requireEventually.Equal(expectStatus, result.Status.Status) + requireEventually.Equalf(expectPhase, fd.Status.Phase, "actual status conditions were: %#v", fd.Status.Conditions) // If the FederationDomain was successfully created, ensure all secrets are present before continuing - if expectStatus == configv1alpha1.SuccessFederationDomainStatusCondition { - requireEventually.NotEmpty(result.Status.Secrets.JWKS.Name, "expected status.secrets.jwks.name not to be empty") - requireEventually.NotEmpty(result.Status.Secrets.TokenSigningKey.Name, "expected status.secrets.tokenSigningKey.name not to be empty") - requireEventually.NotEmpty(result.Status.Secrets.StateSigningKey.Name, "expected status.secrets.stateSigningKey.name not to be empty") - requireEventually.NotEmpty(result.Status.Secrets.StateEncryptionKey.Name, "expected status.secrets.stateEncryptionKey.name not to be empty") + if expectPhase == configv1alpha1.FederationDomainPhaseReady { + requireEventually.NotEmpty(fd.Status.Secrets.JWKS.Name, "expected status.secrets.jwks.name not to be empty") + requireEventually.NotEmpty(fd.Status.Secrets.TokenSigningKey.Name, "expected status.secrets.tokenSigningKey.name not to be empty") + requireEventually.NotEmpty(fd.Status.Secrets.StateSigningKey.Name, "expected status.secrets.stateSigningKey.name not to be empty") + requireEventually.NotEmpty(fd.Status.Secrets.StateEncryptionKey.Name, "expected status.secrets.stateEncryptionKey.name not to be empty") } - }, 60*time.Second, 1*time.Second, "expected the FederationDomain to have status %q", expectStatus) - return federationDomain + }, 60*time.Second, 1*time.Second, "expected the FederationDomain to have status %q", expectPhase) +} + +func WaitForFederationDomainStatusConditions(ctx context.Context, t *testing.T, federationDomainName string, expectConditions []metav1.Condition) { + t.Helper() + testEnv := IntegrationEnv(t) + federationDomainsClient := NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(testEnv.SupervisorNamespace) + + RequireEventuallyf(t, func(requireEventually *require.Assertions) { + fd, err := federationDomainsClient.Get(ctx, federationDomainName, metav1.GetOptions{}) + requireEventually.NoError(err) + + requireEventually.Lenf(fd.Status.Conditions, len(expectConditions), + "wanted status conditions: %#v", expectConditions) + + for i, wantCond := range expectConditions { + actualCond := fd.Status.Conditions[i] + + // This is a cheat to avoid needing to make equality assertions on these fields. + requireEventually.NotZero(actualCond.LastTransitionTime) + wantCond.LastTransitionTime = actualCond.LastTransitionTime + requireEventually.NotZero(actualCond.ObservedGeneration) + wantCond.ObservedGeneration = actualCond.ObservedGeneration + + requireEventually.Equalf(wantCond, actualCond, + "wanted status conditions: %#v\nactual status conditions were: %#v\nnot equal at index %d", + expectConditions, fd.Status.Conditions, i) + } + }, 60*time.Second, 1*time.Second, "wanted FederationDomain conditions") } func RandBytes(t *testing.T, numBytes int) []byte { @@ -475,6 +501,11 @@ func createOIDCClientSecret(t *testing.T, forOIDCClient *configv1alpha1.OIDCClie } func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityProviderSpec, expectedPhase idpv1alpha1.OIDCIdentityProviderPhase) *idpv1alpha1.OIDCIdentityProvider { + t.Helper() + return CreateTestOIDCIdentityProviderWithObjectMeta(t, spec, testObjectMeta(t, "upstream-oidc-idp"), expectedPhase) +} + +func CreateTestOIDCIdentityProviderWithObjectMeta(t *testing.T, spec idpv1alpha1.OIDCIdentityProviderSpec, objectMeta metav1.ObjectMeta, expectedPhase idpv1alpha1.OIDCIdentityProviderPhase) *idpv1alpha1.OIDCIdentityProvider { t.Helper() env := IntegrationEnv(t) client := NewSupervisorClientset(t) @@ -485,7 +516,7 @@ func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityP // Create the OIDCIdentityProvider using GenerateName to get a random name. created, err := upstreams.Create(ctx, &idpv1alpha1.OIDCIdentityProvider{ - ObjectMeta: testObjectMeta(t, "upstream-oidc-idp"), + ObjectMeta: objectMeta, Spec: spec, }, metav1.CreateOptions{}) require.NoError(t, err) @@ -494,7 +525,11 @@ func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityP t.Cleanup(func() { t.Logf("cleaning up test OIDCIdentityProvider %s/%s", created.Namespace, created.Name) err := upstreams.Delete(context.Background(), created.Name, metav1.DeleteOptions{}) - require.NoError(t, err) + notFound := k8serrors.IsNotFound(err) + // It's okay if it is not found, because it might have been deleted by another part of this test. + if !notFound { + require.NoErrorf(t, err, "could not cleanup test OIDCIdentityProvider %s/%s", created.Namespace, created.Name) + } }) t.Logf("created test OIDCIdentityProvider %s", created.Name) @@ -724,3 +759,11 @@ func testObjectMeta(t *testing.T, baseName string) metav1.ObjectMeta { Annotations: map[string]string{"pinniped.dev/testName": t.Name()}, } } + +func ObjectMetaWithRandomName(t *testing.T, baseName string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + Name: fmt.Sprintf("test-%s-%s", baseName, RandHex(t, 8)), + Labels: map[string]string{"pinniped.dev/test": ""}, + Annotations: map[string]string{"pinniped.dev/testName": t.Name()}, + } +}