diff --git a/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go.tmpl b/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go.tmpl index 207249b28..cbe3eeeb0 100644 --- a/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go.tmpl +++ b/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go.tmpl @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/cmd/pinniped/cmd/login_oidc_test.go b/cmd/pinniped/cmd/login_oidc_test.go index f023e0757..42b84f9fa 100644 --- a/cmd/pinniped/cmd/login_oidc_test.go +++ b/cmd/pinniped/cmd/login_oidc_test.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" "time" @@ -22,6 +21,7 @@ import ( "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/pkg/oidcclient" "go.pinniped.dev/pkg/oidcclient/oidctypes" @@ -596,15 +596,7 @@ func TestLoginOIDCCommand(t *testing.T) { require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") require.Len(t, gotOptions, tt.wantOptionsCount) - require.Equal(t, tt.wantLogs, logLines(buf.String())) + require.Equal(t, tt.wantLogs, testutil.SplitByNewline(buf.String())) }) } } - -func logLines(logs string) []string { - if len(logs) == 0 { - return nil - } - - return strings.Split(strings.TrimSpace(logs), "\n") -} diff --git a/cmd/pinniped/cmd/login_static_test.go b/cmd/pinniped/cmd/login_static_test.go index e2f520bdc..19511edfc 100644 --- a/cmd/pinniped/cmd/login_static_test.go +++ b/cmd/pinniped/cmd/login_static_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cmd @@ -20,6 +20,7 @@ import ( "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/plog" + "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/conciergeclient" ) @@ -215,7 +216,7 @@ func TestLoginStaticCommand(t *testing.T) { require.Equal(t, tt.wantStdout, stdout.String(), "unexpected stdout") require.Equal(t, tt.wantStderr, stderr.String(), "unexpected stderr") - require.Equal(t, tt.wantLogs, logLines(buf.String())) + require.Equal(t, tt.wantLogs, testutil.SplitByNewline(buf.String())) }) } } diff --git a/deploy/concierge/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/deploy/concierge/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/deploy/concierge/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/deploy/concierge/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.21/README.adoc b/generated/1.21/README.adoc index 34dca9ce2..677bd9552 100644 --- a/generated/1.21/README.adoc +++ b/generated/1.21/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.21/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-21-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.21/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.21/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.21/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.21/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.21/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.21/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 72b55d000..e762c8b5c 100644 --- a/generated/1.21/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.21/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -150,6 +150,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.22/README.adoc b/generated/1.22/README.adoc index f8943df40..de663d43f 100644 --- a/generated/1.22/README.adoc +++ b/generated/1.22/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-22-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.22/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.22/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.22/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.22/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.22/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.22/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 72b55d000..e762c8b5c 100644 --- a/generated/1.22/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.22/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -150,6 +150,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.23/README.adoc b/generated/1.23/README.adoc index 8b0a61615..d542fc70c 100644 --- a/generated/1.23/README.adoc +++ b/generated/1.23/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.23/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-23-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.23/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.23/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.23/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.23/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.23/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.23/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.23/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.23/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.24/README.adoc b/generated/1.24/README.adoc index 89c550839..782f1cc2b 100644 --- a/generated/1.24/README.adoc +++ b/generated/1.24/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-24-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.24/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.24/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.24/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.24/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.24/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.24/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.24/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.24/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.25/README.adoc b/generated/1.25/README.adoc index 0f44a7538..cb137d202 100644 --- a/generated/1.25/README.adoc +++ b/generated/1.25/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.25/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-25-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.25/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.25/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.25/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.25/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.25/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.25/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.25/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.25/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.26/README.adoc b/generated/1.26/README.adoc index 406eac404..17170f767 100644 --- a/generated/1.26/README.adoc +++ b/generated/1.26/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-26-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.26/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.26/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.26/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.26/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.26/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.26/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.26/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.26/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.27/README.adoc b/generated/1.27/README.adoc index 9bafbbbff..026512700 100644 --- a/generated/1.27/README.adoc +++ b/generated/1.27/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.27/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-27-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.27/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.27/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.27/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.27/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.27/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.27/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.27/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.27/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.28/README.adoc b/generated/1.28/README.adoc index 0aedac991..9f6f45886 100644 --- a/generated/1.28/README.adoc +++ b/generated/1.28/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.28/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-28-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.28/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.28/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.28/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.28/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.28/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.28/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.28/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.28/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/1.29/README.adoc b/generated/1.29/README.adoc index 71e2ffc09..dbecc10cf 100644 --- a/generated/1.29/README.adoc +++ b/generated/1.29/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/1.29/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/1.29/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/1.29/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/1.29/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/generated/1.29/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml b/generated/1.29/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml index 08defa182..e8cbc6790 100644 --- a/generated/1.29/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml +++ b/generated/1.29/crds/authentication.concierge.pinniped.dev_webhookauthenticators.yaml @@ -144,6 +144,14 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + phase: + default: Pending + description: Phase summarizes the overall status of the WebhookAuthenticator. + enum: + - Pending + - Ready + - Error + type: string type: object required: - spec diff --git a/generated/latest/README.adoc b/generated/latest/README.adoc index 71e2ffc09..dbecc10cf 100644 --- a/generated/latest/README.adoc +++ b/generated/latest/README.adoc @@ -154,6 +154,18 @@ WebhookAuthenticator describes the configuration of a webhook authenticator. +[id="{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase"] +==== WebhookAuthenticatorPhase (string) + + + +.Appears In: +**** +- xref:{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorstatus[$$WebhookAuthenticatorStatus$$] +**** + + + [id="{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorspec"] ==== WebhookAuthenticatorSpec @@ -186,6 +198,7 @@ Status of a webhook authenticator. |=== | Field | Description | *`conditions`* __link:https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#condition-v1-meta[$$Condition$$] array__ | Represents the observations of the authenticator's current state. +| *`phase`* __xref:{anchor_prefix}-go-pinniped-dev-generated-1-29-apis-concierge-authentication-v1alpha1-webhookauthenticatorphase[$$WebhookAuthenticatorPhase$$]__ | Phase summarizes the overall status of the WebhookAuthenticator. |=== diff --git a/generated/latest/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go b/generated/latest/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go index 207249b28..cbe3eeeb0 100644 --- a/generated/latest/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go +++ b/generated/latest/apis/concierge/authentication/v1alpha1/types_webhookauthenticator.go @@ -1,10 +1,23 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package v1alpha1 import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +type WebhookAuthenticatorPhase string + +const ( + // WebhookAuthenticatorPhasePending is the default phase for newly-created WebhookAuthenticator resources. + WebhookAuthenticatorPhasePending WebhookAuthenticatorPhase = "Pending" + + // WebhookAuthenticatorPhaseReady is the phase for an WebhookAuthenticator resource in a healthy state. + WebhookAuthenticatorPhaseReady WebhookAuthenticatorPhase = "Ready" + + // WebhookAuthenticatorPhaseError is the phase for an WebhookAuthenticator in an unhealthy state. + WebhookAuthenticatorPhaseError WebhookAuthenticatorPhase = "Error" +) + // Status of a webhook authenticator. type WebhookAuthenticatorStatus struct { // Represents the observations of the authenticator's current state. @@ -13,6 +26,10 @@ type WebhookAuthenticatorStatus struct { // +listType=map // +listMapKey=type Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` + // Phase summarizes the overall status of the WebhookAuthenticator. + // +kubebuilder:default=Pending + // +kubebuilder:validation:Enum=Pending;Ready;Error + Phase WebhookAuthenticatorPhase `json:"phase,omitempty"` } // Spec for configuring a webhook authenticator. diff --git a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go index c4dd46884..951db2bfa 100644 --- a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go +++ b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller.go @@ -53,18 +53,21 @@ const ( typeJWKSFetchValid = "JWKSFetchValid" typeAuthenticatorValid = "AuthenticatorValid" - reasonSuccess = "Success" - reasonNotReady = "NotReady" - reasonUnableToValidate = "UnableToValidate" - reasonInvalidIssuerURL = "InvalidIssuerURL" - reasonInvalidIssuerURLScheme = "InvalidIssuerURLScheme" - reasonInvalidProviderJWKSURL = "InvalidProviderJWKSURL" - reasonInvalidProviderJWKSURLScheme = "InvalidProviderJWKSURLScheme" - reasonInvalidTLSConfiguration = "InvalidTLSConfiguration" - reasonInvalidDiscoveryProbe = "InvalidDiscoveryProbe" - reasonInvalidAuthenticator = "InvalidAuthenticator" - reasonInvalidTokenSigningFailure = "InvalidTokenSigningFailure" - reasonInvalidCouldNotFetchJWKS = "InvalidCouldNotFetchJWKS" + reasonSuccess = "Success" + reasonNotReady = "NotReady" + reasonUnableToValidate = "UnableToValidate" + reasonInvalidIssuerURL = "InvalidIssuerURL" + reasonInvalidIssuerURLScheme = "InvalidIssuerURLScheme" + reasonInvalidIssuerURLFragment = "InvalidIssuerURLContainsFragment" + reasonInvalidIssuerURLQueryParams = "InvalidIssuerURLContainsQueryParams" + reasonInvalidIssuerURLContainsWellKnownEndpoint = "InvalidIssuerURLContainsWellKnownEndpoint" + reasonInvalidProviderJWKSURL = "InvalidProviderJWKSURL" + reasonInvalidProviderJWKSURLScheme = "InvalidProviderJWKSURLScheme" + reasonInvalidTLSConfiguration = "InvalidTLSConfiguration" + reasonInvalidDiscoveryProbe = "InvalidDiscoveryProbe" + reasonInvalidAuthenticator = "InvalidAuthenticator" + reasonInvalidTokenSigningFailure = "InvalidTokenSigningFailure" + reasonInvalidCouldNotFetchJWKS = "InvalidCouldNotFetchJWKS" msgUnableToValidate = "unable to validate; see other conditions for details" @@ -286,6 +289,39 @@ func (c *jwtCacheFillerController) validateIssuer(issuer string, conditions []*m return nil, conditions, false } + if strings.HasSuffix(issuerURL.Path, "/.well-known/openid-configuration") { + msg := fmt.Sprintf("spec.issuer %s cannot include path '/.well-known/openid-configuration'", issuer) + conditions = append(conditions, &metav1.Condition{ + Type: typeIssuerURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidIssuerURLContainsWellKnownEndpoint, + Message: msg, + }) + return nil, conditions, false + } + + if len(issuerURL.Query()) != 0 { + msg := fmt.Sprintf("spec.issuer %s cannot include query params", issuer) + conditions = append(conditions, &metav1.Condition{ + Type: typeIssuerURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidIssuerURLQueryParams, + Message: msg, + }) + return nil, conditions, false + } + + if issuerURL.Fragment != "" { + msg := fmt.Sprintf("spec.issuer %s cannot include fragment", issuer) + conditions = append(conditions, &metav1.Condition{ + Type: typeIssuerURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidIssuerURLFragment, + Message: msg, + }) + return nil, conditions, false + } + conditions = append(conditions, &metav1.Condition{ Type: typeIssuerURLValid, Status: metav1.ConditionTrue, @@ -305,11 +341,12 @@ func (c *jwtCacheFillerController) validateProviderDiscovery(ctx context.Context }) return nil, nil, conditions, nil } + provider, err := coreosoidc.NewProvider(ctx, issuer) pJSON := &providerJSON{} if err != nil { errText := "could not perform oidc discovery on provider issuer" - msg := fmt.Sprintf("%s: %s", errText, err.Error()) + msg := fmt.Sprintf("%s: %s", errText, pinnipedcontroller.TruncateMostLongErr(err)) conditions = append(conditions, &metav1.Condition{ Type: typeDiscoveryValid, Status: metav1.ConditionFalse, @@ -317,7 +354,7 @@ func (c *jwtCacheFillerController) validateProviderDiscovery(ctx context.Context Message: msg, }) // resync err, may be machine or other types of non-config error - return nil, nil, conditions, fmt.Errorf("%s: %w", errText, err) + return nil, nil, conditions, fmt.Errorf("%s: %s", errText, err) } msg := "discovery performed successfully" conditions = append(conditions, &metav1.Condition{ diff --git a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller_test.go b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller_test.go index d87e71abf..3e1868f22 100644 --- a/internal/controller/authenticator/jwtcachefiller/jwtcachefiller_test.go +++ b/internal/controller/authenticator/jwtcachefiller/jwtcachefiller_test.go @@ -10,10 +10,7 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" - "crypto/tls" - "encoding/base64" "encoding/json" - "encoding/pem" "errors" "fmt" "net/http" @@ -46,6 +43,7 @@ import ( "go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser" "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" + "go.pinniped.dev/internal/testutil/conciergetestutil" "go.pinniped.dev/internal/testutil/conditionstestutil" "go.pinniped.dev/internal/testutil/tlsserver" ) @@ -88,6 +86,20 @@ func TestController(t *testing.T) { _, err := fmt.Fprintf(w, `{"issuer": "%s", "jwks_uri": "%s"}`, goodOIDCIssuerServer.URL, goodOIDCIssuerServer.URL+"/jwks.json") require.NoError(t, err) })) + goodMux.Handle("/path/to/not/found", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + require.NoError(t, err) + })) + goodMux.Handle("/path/to/not/found/.well-known/openid-configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, err := fmt.Fprintf(w, ` + %s + %s + `, "404 not found page", "lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz should not reach end of string") + require.NoError(t, err) + })) goodMux.Handle("/jwks.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ecJWK := jose.JSONWebKey{ Key: goodECSigningKey, @@ -209,12 +221,12 @@ func TestController(t *testing.T) { someJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: goodIssuer, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), } someJWTAuthenticatorSpecWithUsernameClaim := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: goodIssuer, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), Claims: auth1alpha1.JWTTokenClaims{ Username: "my-custom-username-claim", }, @@ -222,7 +234,7 @@ func TestController(t *testing.T) { someJWTAuthenticatorSpecWithGroupsClaim := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: goodIssuer, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), Claims: auth1alpha1.JWTTokenClaims{ Groups: customGroupsClaim, }, @@ -248,12 +260,12 @@ func TestController(t *testing.T) { invalidIssuerJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: "https://.café .com/café/café/café/coffee", Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), } invalidIssuerSchemeJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: "http://.café.com/café/café/café/coffee", Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), } validIssuerURLButDoesNotExistJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ @@ -263,18 +275,18 @@ func TestController(t *testing.T) { badIssuerJWKSURIJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: badIssuerInvalidJWKSURI, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(badOIDCIssuerServerInvalidJWKSURI.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(badOIDCIssuerServerInvalidJWKSURI.TLS), } badIssuerJWKSURISchemeJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: badIssuerInvalidJWKSURIScheme, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(badOIDCIssuerServerInvalidJWKSURIScheme.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(badOIDCIssuerServerInvalidJWKSURIScheme.TLS), } jwksFetchShouldFailJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ Issuer: jwksFetchShouldFailServer.URL, Audience: goodAudience, - TLS: tlsSpecFromTLSConfig(jwksFetchShouldFailServer.TLS), + TLS: conciergetestutil.TLSSpecFromTLSConfig(jwksFetchShouldFailServer.TLS), } happyReadyCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { @@ -361,6 +373,39 @@ func TestController(t *testing.T) { } } + sadIssuerURLValidInvalidFragment := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidIssuerURLContainsFragment", + Message: fmt.Sprintf("spec.issuer %s cannot include fragment", issuer), + } + } + + sadIssuerURLValidInvalidQueryParams := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidIssuerURLContainsQueryParams", + Message: fmt.Sprintf("spec.issuer %s cannot include query params", issuer), + } + } + + sadIssuerURLValidInvalidWellKnownEndpoint := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "IssuerURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidIssuerURLContainsWellKnownEndpoint", + Message: fmt.Sprintf("spec.issuer %s cannot include path '/.well-known/openid-configuration'", issuer), + } + } + happyAuthenticatorValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { return metav1.Condition{ Type: "AuthenticatorValid", @@ -429,6 +474,17 @@ func TestController(t *testing.T) { } } + sadDiscoveryURLValidExcessiveLongError := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "DiscoveryURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidDiscoveryProbe", + Message: "could not perform oidc discovery on provider issuer: 404 Not Found: \n\t\t \t404 not found page\n\t\t\tlots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 charact [truncated 534 chars]", + } + } + happyJWKSURLValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { return metav1.Condition{ Type: "JWKSURLValid", @@ -597,21 +653,22 @@ func TestController(t *testing.T) { jwtAuthenticators: []runtime.Object{ &auth1alpha1.JWTAuthenticator{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-name", + Name: "test-name", + Generation: 1234, }, Spec: *someJWTAuthenticatorSpec, Status: auth1alpha1.JWTAuthenticatorStatus{ Conditions: conditionstestutil.Replace( - allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 1233), []metav1.Condition{ // sad and unknwn will update with new statuses and timestamps - sadReadyCondition(frozenTimeInThePast, 0), - sadDiscoveryURLValidx509(goodIssuer, frozenTimeInThePast, 0), - unknownAuthenticatorValid(frozenTimeInThePast, 0), - unknownJWKSURLValid(frozenTimeInThePast, 0), - unknownJWKSFetch(frozenTimeInThePast, 0), + sadReadyCondition(frozenTimeInThePast, 1232), + sadDiscoveryURLValidx509(goodIssuer, frozenTimeInThePast, 1231), + unknownAuthenticatorValid(frozenTimeInThePast, 1232), + unknownJWKSURLValid(frozenTimeInThePast, 1111), + unknownJWKSFetch(frozenTimeInThePast, 1122), // this one will remain unchanged as it was good to begin with - happyTLSConfigurationValidCAParsed(frozenTimeInThePast, 0), + happyTLSConfigurationValidCAParsed(frozenTimeInThePast, 4321), }, ), Phase: "Error", @@ -631,15 +688,16 @@ func TestController(t *testing.T) { wantActions: func() []coretesting.Action { updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &auth1alpha1.JWTAuthenticator{ ObjectMeta: metav1.ObjectMeta{ - Name: "test-name", + Name: "test-name", + Generation: 1234, }, Spec: *someJWTAuthenticatorSpec, Status: auth1alpha1.JWTAuthenticatorStatus{ Conditions: conditionstestutil.Replace( - allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 1234), []metav1.Condition{ // this timestamp should not have updated, it didn't change. - happyTLSConfigurationValidCAParsed(frozenTimeInThePast, 0), + happyTLSConfigurationValidCAParsed(frozenTimeInThePast, 1234), }, ), Phase: "Ready", @@ -1096,6 +1154,147 @@ func TestController(t *testing.T) { updateStatusAction, } }, + }, { + name: "validateIssuer: issuer cannot include fragment: loop will fail sync, will write failed and unknown conditions, but will not enqueue a resync due to user config error", + jwtAuthenticators: []runtime.Object{ + &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/#do-not-include-fragment", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + }, + }, + syncKey: controllerlib.Key{Name: "test-name"}, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/#do-not-include-fragment", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + Status: auth1alpha1.JWTAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + []metav1.Condition{ + sadReadyCondition(frozenMetav1Now, 0), + sadIssuerURLValidInvalidFragment("https://www.example.com/foo/bar/#do-not-include-fragment", frozenMetav1Now, 0), + unknownDiscoveryURLValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + unknownJWKSURLValid(frozenMetav1Now, 0), + unknownJWKSFetch(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + }, { + name: "validateIssuer: issuer cannot include query params: loop will fail sync, will write failed and unknown conditions, but will not enqueue a resync due to user config error", + jwtAuthenticators: []runtime.Object{ + &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/?query-params=not-allowed", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + }, + }, + syncKey: controllerlib.Key{Name: "test-name"}, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/?query-params=not-allowed", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + Status: auth1alpha1.JWTAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + []metav1.Condition{ + sadReadyCondition(frozenMetav1Now, 0), + sadIssuerURLValidInvalidQueryParams("https://www.example.com/foo/bar/?query-params=not-allowed", frozenMetav1Now, 0), + unknownDiscoveryURLValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + unknownJWKSURLValid(frozenMetav1Now, 0), + unknownJWKSFetch(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + }, { + name: "validateIssuer: issuer cannot include .well-known in path: loop will fail sync, will write failed and unknown conditions, but will not enqueue a resync due to user config error", + jwtAuthenticators: []runtime.Object{ + &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/.well-known/openid-configuration", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + }, + }, + syncKey: controllerlib.Key{Name: "test-name"}, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: "https://www.example.com/foo/bar/.well-known/openid-configuration", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + Status: auth1alpha1.JWTAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + []metav1.Condition{ + sadReadyCondition(frozenMetav1Now, 0), + sadIssuerURLValidInvalidWellKnownEndpoint("https://www.example.com/foo/bar/.well-known/openid-configuration", frozenMetav1Now, 0), + unknownDiscoveryURLValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + unknownJWKSURLValid(frozenMetav1Now, 0), + unknownJWKSFetch(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, }, { name: "validateProviderDiscovery: could not perform oidc discovery on provider issuer: loop will fail sync, will write failed and unknown conditions, and will enqueue new sync", jwtAuthenticators: []runtime.Object{ @@ -1137,6 +1336,56 @@ func TestController(t *testing.T) { } }, wantSyncLoopErr: testutil.WantExactErrorString(`could not perform oidc discovery on provider issuer: Get "` + goodIssuer + `/foo/bar/baz/shizzle/.well-known/openid-configuration": tls: failed to verify certificate: x509: certificate signed by unknown authority`), + }, { + name: "validateProviderDiscovery: excessively long errors truncated: loop will fail sync, will write failed and unknown conditions, and will enqueue new sync", + jwtAuthenticators: []runtime.Object{ + &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: goodIssuer + "/path/to/not/found", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + }, + }, + syncKey: controllerlib.Key{Name: "test-name"}, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &auth1alpha1.JWTAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.JWTAuthenticatorSpec{ + Issuer: goodIssuer + "/path/to/not/found", + Audience: goodAudience, + TLS: conciergetestutil.TLSSpecFromTLSConfig(goodOIDCIssuerServer.TLS), + }, + Status: auth1alpha1.JWTAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0), + []metav1.Condition{ + happyIssuerURLValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + sadDiscoveryURLValidExcessiveLongError(goodIssuer+"/path/to/not/found", frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + unknownJWKSURLValid(frozenMetav1Now, 0), + unknownJWKSFetch(frozenMetav1Now, 0), + happyTLSConfigurationValidCAParsed(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + // not currently truncating the logged err + wantSyncLoopErr: testutil.WantExactErrorString("could not perform oidc discovery on provider issuer: 404 Not Found: \n\t\t \t404 not found page\n\t\t\tlots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz lots of text that is at least 300 characters long 0123456789 abcdefghijklmnopqrstuvwxyz should not reach end of string\n\t\t"), }, // cannot be tested currently the way the coreos lib works. // the constructor requires an issuer in the payload and validates the issuer matches the actual issuer, @@ -1455,7 +1704,7 @@ func TestController(t *testing.T) { require.NoError(t, err) } - actualLogLines := logLines(log.String()) + actualLogLines := testutil.SplitByNewline(log.String()) require.Equal(t, len(tt.wantLogs), len(actualLogLines), "log line count should be correct") for logLineNum, logLine := range actualLogLines { @@ -1800,21 +2049,6 @@ func testTableForAuthenticateTokenTests( return tests } -func tlsSpecFromTLSConfig(tls *tls.Config) *auth1alpha1.TLSSpec { - pemData := make([]byte, 0) - for _, certificate := range tls.Certificates { - for _, reallyCertificate := range certificate.Certificate { - pemData = append(pemData, pem.EncodeToMemory(&pem.Block{ - Type: "CERTIFICATE", - Bytes: reallyCertificate, - })...) - } - } - return &auth1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemData), - } -} - func createJWT( t *testing.T, signingKey interface{}, @@ -1868,11 +2102,3 @@ func newCacheValue(t *testing.T, spec auth1alpha1.JWTAuthenticatorSpec, wantClos spec: &spec, } } - -func logLines(logs string) []string { - if len(logs) == 0 { - return nil - } - - return strings.Split(strings.TrimSpace(logs), "\n") -} diff --git a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller.go b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller.go index c7b24cc6f..a6ebd96a9 100644 --- a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller.go +++ b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller.go @@ -1,41 +1,83 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package webhookcachefiller implements a controller for filling an authncache.Cache with each added/updated WebhookAuthenticator. package webhookcachefiller import ( + "context" + "crypto/tls" + "crypto/x509" "fmt" + "net/url" "os" - "github.com/go-logr/logr" k8sauthv1beta1 "k8s.io/api/authentication/v1beta1" + "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/util/net" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + errorsutil "k8s.io/apimachinery/pkg/util/errors" + k8snetutil "k8s.io/apimachinery/pkg/util/net" "k8s.io/apiserver/pkg/authentication/authenticator" webhookutil "k8s.io/apiserver/pkg/util/webhook" "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" + "k8s.io/utils/clock" auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" + conciergeclientset "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned" authinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions/authentication/v1alpha1" pinnipedcontroller "go.pinniped.dev/internal/controller" pinnipedauthenticator "go.pinniped.dev/internal/controller/authenticator" "go.pinniped.dev/internal/controller/authenticator/authncache" + "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/endpointaddr" + "go.pinniped.dev/internal/plog" +) + +const ( + controllerName = "webhookcachefiller-controller" + typeReady = "Ready" + typeTLSConfigurationValid = "TLSConfigurationValid" + typeWebhookConnectionValid = "WebhookConnectionValid" + typeEndpointURLValid = "EndpointURLValid" + typeAuthenticatorValid = "AuthenticatorValid" + reasonSuccess = "Success" + reasonNotReady = "NotReady" + reasonUnableToValidate = "UnableToValidate" + reasonUnableToCreateTempFile = "UnableToCreateTempFile" + reasonUnableToMarshallKubeconfig = "UnableToMarshallKubeconfig" + reasonUnableToLoadKubeconfig = "UnableToLoadKubeconfig" + reasonUnableToInstantiateWebhook = "UnableToInstantiateWebhook" + reasonInvalidTLSConfiguration = "InvalidTLSConfiguration" + reasonInvalidEndpointURL = "InvalidEndpointURL" + reasonInvalidEndpointURLScheme = "InvalidEndpointURLScheme" + reasonUnableToDialServer = "UnableToDialServer" + msgUnableToValidate = "unable to validate; see other conditions for details" ) // New instantiates a new controllerlib.Controller which will populate the provided authncache.Cache. -func New(cache *authncache.Cache, webhooks authinformers.WebhookAuthenticatorInformer, log logr.Logger) controllerlib.Controller { +func New( + cache *authncache.Cache, + client conciergeclientset.Interface, + webhooks authinformers.WebhookAuthenticatorInformer, + clock clock.Clock, + log plog.Logger, + tlsDialerFunc func(network string, addr string, config *tls.Config) (*tls.Conn, error), +) controllerlib.Controller { return controllerlib.New( controllerlib.Config{ - Name: "webhookcachefiller-controller", - Syncer: &controller{ - cache: cache, - webhooks: webhooks, - log: log.WithName("webhookcachefiller-controller"), + Name: controllerName, + Syncer: &webhookCacheFillerController{ + cache: cache, + client: client, + webhooks: webhooks, + clock: clock, + log: log.WithName(controllerName), + tlsDialerFunc: tlsDialerFunc, }, }, controllerlib.WithInformer( @@ -46,14 +88,17 @@ func New(cache *authncache.Cache, webhooks authinformers.WebhookAuthenticatorInf ) } -type controller struct { - cache *authncache.Cache - webhooks authinformers.WebhookAuthenticatorInformer - log logr.Logger +type webhookCacheFillerController struct { + cache *authncache.Cache + webhooks authinformers.WebhookAuthenticatorInformer + client conciergeclientset.Interface + clock clock.Clock + log plog.Logger + tlsDialerFunc func(network string, addr string, config *tls.Config) (*tls.Conn, error) } // Sync implements controllerlib.Syncer. -func (c *controller) Sync(ctx controllerlib.Context) error { +func (c *webhookCacheFillerController) Sync(ctx controllerlib.Context) error { obj, err := c.webhooks.Lister().Get(ctx.Key.Name) if err != nil && errors.IsNotFound(err) { c.log.Info("Sync() found that the WebhookAuthenticator does not exist yet or was deleted") @@ -63,38 +108,82 @@ func (c *controller) Sync(ctx controllerlib.Context) error { return fmt.Errorf("failed to get WebhookAuthenticator %s/%s: %w", ctx.Key.Namespace, ctx.Key.Name, err) } - webhookAuthenticator, err := newWebhookAuthenticator(&obj.Spec, os.CreateTemp, clientcmd.WriteToFile) - if err != nil { - return fmt.Errorf("failed to build webhook config: %w", err) + conditions := make([]*metav1.Condition, 0) + specCopy := obj.Spec.DeepCopy() + var errs []error + + certPool, pemBytes, conditions, tlsBundleOk := c.validateTLSBundle(specCopy.TLS, conditions) + endpointHostPort, conditions, endpointOk := c.validateEndpoint(specCopy.Endpoint, conditions) + okSoFar := tlsBundleOk && endpointOk + conditions, tlsNegotiateErr := c.validateConnection(certPool, endpointHostPort, conditions, okSoFar) + errs = append(errs, tlsNegotiateErr) + okSoFar = okSoFar && tlsNegotiateErr == nil + + webhookAuthenticator, conditions, err := newWebhookAuthenticator( + specCopy.Endpoint, + pemBytes, + os.CreateTemp, + clientcmd.WriteToFile, + conditions, + okSoFar, + ) + errs = append(errs, err) + + if !conditionsutil.HadErrorCondition(conditions) { + c.cache.Store(authncache.Key{ + APIGroup: auth1alpha1.GroupName, + Kind: "WebhookAuthenticator", + Name: ctx.Key.Name, + }, webhookAuthenticator) + c.log.WithValues("webhook", klog.KObj(obj), "endpoint", obj.Spec.Endpoint).Info("added new webhook authenticator") } - c.cache.Store(authncache.Key{ - APIGroup: auth1alpha1.GroupName, - Kind: "WebhookAuthenticator", - Name: ctx.Key.Name, - }, webhookAuthenticator) - c.log.WithValues("webhook", klog.KObj(obj), "endpoint", obj.Spec.Endpoint).Info("added new webhook authenticator") - return nil + err = c.updateStatus(ctx.Context, obj, conditions) + errs = append(errs, err) + + // sync loop errors: + // - should not be configuration errors. config errors a user must correct belong on the .Status + // object. The controller simply must wait for a user to correct before running again. + // - other errors, such as networking errors, etc. are the types of errors that should return here + // and signal the controller to retry the sync loop. These may be corrected by machines. + return errorsutil.NewAggregate(errs) } // newWebhookAuthenticator creates a webhook from the provided API server url and caBundle // used to validate TLS connections. func newWebhookAuthenticator( - spec *auth1alpha1.WebhookAuthenticatorSpec, + endpoint string, + pemBytes []byte, tempfileFunc func(string, string) (*os.File, error), marshalFunc func(clientcmdapi.Config, string) error, -) (*webhook.WebhookTokenAuthenticator, error) { + conditions []*metav1.Condition, + prereqOk bool, +) (*webhook.WebhookTokenAuthenticator, []*metav1.Condition, error) { + if !prereqOk { + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionUnknown, + Reason: reasonUnableToValidate, + Message: msgUnableToValidate, + }) + return nil, conditions, nil + } temp, err := tempfileFunc("", "pinniped-webhook-kubeconfig-*") if err != nil { - return nil, fmt.Errorf("unable to create temporary file: %w", err) + errText := "unable to create temporary file" + msg := fmt.Sprintf("%s: %s", errText, err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionFalse, + Reason: reasonUnableToCreateTempFile, + Message: msg, + }) + return nil, conditions, fmt.Errorf("%s: %w", errText, err) } defer func() { _ = os.Remove(temp.Name()) }() - cluster := &clientcmdapi.Cluster{Server: spec.Endpoint} - _, cluster.CertificateAuthorityData, err = pinnipedauthenticator.CABundle(spec.TLS) - if err != nil { - return nil, fmt.Errorf("invalid TLS configuration: %w", err) - } + cluster := &clientcmdapi.Cluster{Server: endpoint} + cluster.CertificateAuthorityData = pemBytes kubeconfig := clientcmdapi.NewConfig() kubeconfig.Clusters["anonymous-cluster"] = cluster @@ -102,7 +191,15 @@ func newWebhookAuthenticator( kubeconfig.CurrentContext = "anonymous" if err := marshalFunc(*kubeconfig, temp.Name()); err != nil { - return nil, fmt.Errorf("unable to marshal kubeconfig: %w", err) + errText := "unable to marshal kubeconfig" + msg := fmt.Sprintf("%s: %s", errText, err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionFalse, + Reason: reasonUnableToMarshallKubeconfig, + Message: msg, + }) + return nil, conditions, fmt.Errorf("%s: %w", errText, err) } // We use v1beta1 instead of v1 since v1beta1 is more prevalent in our desired @@ -116,7 +213,7 @@ func newWebhookAuthenticator( // We set this to nil because we would only need this to support some of the // custom proxy stuff used by the API server. - var customDial net.DialFunc + var customDial k8snetutil.DialFunc // TODO refactor this code to directly construct the rest.Config // ideally we would keep rest config generation contained to the kubeclient package @@ -136,10 +233,196 @@ func newWebhookAuthenticator( // then use client.JSONConfig as clientConfig clientConfig, err := webhookutil.LoadKubeconfig(temp.Name(), customDial) if err != nil { - return nil, err + // no unit test for this failure. + errText := "unable to load kubeconfig" + msg := fmt.Sprintf("%s: %s", errText, err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionFalse, + Reason: reasonUnableToLoadKubeconfig, + Message: msg, + }) + return nil, conditions, fmt.Errorf("%s: %w", errText, err) } // this uses a http client that does not honor our TLS config - // TODO fix when we pick up https://github.com/kubernetes/kubernetes/pull/106155 - return webhook.New(clientConfig, version, implicitAuds, *webhook.DefaultRetryBackoff()) + // TODO: fix when we pick up https://github.com/kubernetes/kubernetes/pull/106155 + // NOTE: looks like the above was merged on Mar 18, 2022 + webhookA, err := webhook.New(clientConfig, version, implicitAuds, *webhook.DefaultRetryBackoff()) + if err != nil { + // no unit test for this failure. + errText := "unable to instantiate webhook" + msg := fmt.Sprintf("%s: %s", errText, err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionFalse, + Reason: reasonUnableToInstantiateWebhook, + Message: msg, + }) + return nil, conditions, fmt.Errorf("%s: %w", errText, err) + } + msg := "authenticator initialized" + conditions = append(conditions, &metav1.Condition{ + Type: typeAuthenticatorValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: msg, + }) + return webhookA, conditions, nil +} + +func (c *webhookCacheFillerController) validateConnection(certPool *x509.CertPool, endpointHostPort *endpointaddr.HostPort, conditions []*metav1.Condition, prereqOk bool) ([]*metav1.Condition, error) { + if !prereqOk { + conditions = append(conditions, &metav1.Condition{ + Type: typeWebhookConnectionValid, + Status: metav1.ConditionUnknown, + Reason: reasonUnableToValidate, + Message: msgUnableToValidate, + }) + return conditions, nil + } + + conn, err := c.tlsDialerFunc("tcp", endpointHostPort.Endpoint(), &tls.Config{ + MinVersion: tls.VersionTLS12, + // If certPool is nil then RootCAs will be set to nil and TLS will use the host's root CA set automatically. + RootCAs: certPool, + }) + + if err != nil { + errText := "cannot dial server" + msg := fmt.Sprintf("%s: %s", errText, err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeWebhookConnectionValid, + Status: metav1.ConditionFalse, + Reason: reasonUnableToDialServer, + Message: msg, + }) + return conditions, fmt.Errorf("%s: %w", errText, err) + } + + // this error should never be significant + err = conn.Close() + if err != nil { + c.log.Error("error closing dialer", err) + } + + conditions = append(conditions, &metav1.Condition{ + Type: typeWebhookConnectionValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "tls verified", + }) + return conditions, nil +} + +func (c *webhookCacheFillerController) validateTLSBundle(tlsSpec *auth1alpha1.TLSSpec, conditions []*metav1.Condition) (*x509.CertPool, []byte, []*metav1.Condition, bool) { + rootCAs, pemBytes, err := pinnipedauthenticator.CABundle(tlsSpec) + if err != nil { + msg := fmt.Sprintf("%s: %s", "invalid TLS configuration", err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeTLSConfigurationValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidTLSConfiguration, + Message: msg, + }) + return rootCAs, pemBytes, conditions, false + } + msg := "successfully parsed specified CA bundle" + if rootCAs == nil { + msg = "no CA bundle specified" + } + conditions = append(conditions, &metav1.Condition{ + Type: typeTLSConfigurationValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: msg, + }) + return rootCAs, pemBytes, conditions, true +} + +func (c *webhookCacheFillerController) validateEndpoint(endpoint string, conditions []*metav1.Condition) (*endpointaddr.HostPort, []*metav1.Condition, bool) { + endpointURL, err := url.Parse(endpoint) + if err != nil { + msg := fmt.Sprintf("%s: %s", "spec.endpoint URL cannot be parsed", err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeEndpointURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidEndpointURL, + Message: msg, + }) + return nil, conditions, false + } + + // handles empty string and other issues as well. + if endpointURL.Scheme != "https" { + msg := fmt.Sprintf("spec.endpoint URL %s has invalid scheme, require 'https'", endpoint) + conditions = append(conditions, &metav1.Condition{ + Type: typeEndpointURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidEndpointURLScheme, + Message: msg, + }) + return nil, conditions, false + } + + endpointHostPort, err := endpointaddr.ParseFromURL(endpointURL, 443) + if err != nil { + msg := fmt.Sprintf("%s: %s", "spec.endpoint URL is not valid", err.Error()) + conditions = append(conditions, &metav1.Condition{ + Type: typeEndpointURLValid, + Status: metav1.ConditionFalse, + Reason: reasonInvalidEndpointURL, + Message: msg, + }) + return nil, conditions, false + } + + conditions = append(conditions, &metav1.Condition{ + Type: typeEndpointURLValid, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "endpoint is a valid URL", + }) + return &endpointHostPort, conditions, true +} + +func (c *webhookCacheFillerController) updateStatus( + ctx context.Context, + original *auth1alpha1.WebhookAuthenticator, + conditions []*metav1.Condition, +) error { + updated := original.DeepCopy() + + if conditionsutil.HadErrorCondition(conditions) { + updated.Status.Phase = auth1alpha1.WebhookAuthenticatorPhaseError + conditions = append(conditions, &metav1.Condition{ + Type: typeReady, + Status: metav1.ConditionFalse, + Reason: reasonNotReady, + Message: "the WebhookAuthenticator is not ready: see other conditions for details", + }) + } else { + updated.Status.Phase = auth1alpha1.WebhookAuthenticatorPhaseReady + conditions = append(conditions, &metav1.Condition{ + Type: typeReady, + Status: metav1.ConditionTrue, + Reason: reasonSuccess, + Message: "the WebhookAuthenticator is ready", + }) + } + + _ = conditionsutil.MergeConfigConditions( + conditions, + original.Generation, + &updated.Status.Conditions, + plog.New().WithName(controllerName), + metav1.NewTime(c.clock.Now()), + ) + + if equality.Semantic.DeepEqual(original, updated) { + return nil + } + + _, err := c.client.AuthenticationV1alpha1().WebhookAuthenticators().UpdateStatus(ctx, updated, metav1.UpdateOptions{}) + return err } diff --git a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go index 291358003..081603236 100644 --- a/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go +++ b/internal/controller/authenticator/webhookcachefiller/webhookcachefiller_test.go @@ -1,52 +1,632 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package webhookcachefiller import ( + "bytes" "context" + "crypto/tls" "encoding/base64" + "encoding/json" + "errors" "fmt" "io" + "net" "net/http" + "net/http/httptest" + "net/url" "os" "testing" + "time" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + coretesting "k8s.io/client-go/testing" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + clocktesting "k8s.io/utils/clock/testing" auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake" pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions" + "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/controller/authenticator/authncache" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/crypto/ptls" + "go.pinniped.dev/internal/plog" "go.pinniped.dev/internal/testutil" - "go.pinniped.dev/internal/testutil/testlogger" + "go.pinniped.dev/internal/testutil/conciergetestutil" + "go.pinniped.dev/internal/testutil/conditionstestutil" + "go.pinniped.dev/internal/testutil/tlsserver" ) func TestController(t *testing.T) { t.Parallel() + caForLocalhostAsHostname, err := certauthority.New("My Localhost CA Common Name", time.Hour) + require.NoError(t, err) + onlyLocalhostAsHost := []string{"localhost"} + noIPAddressesNotEven127001 := []net.IP{} + hostAsLocalhostServingCert, err := caForLocalhostAsHostname.IssueServerCert( + onlyLocalhostAsHost, + noIPAddressesNotEven127001, + time.Hour, + ) + require.NoError(t, err) + + caForLocalhostAs127001, err := certauthority.New("My Localhost CA Common Name", time.Hour) + require.NoError(t, err) + noHostnameHost := []string{} + only127001IPAddress := []net.IP{net.ParseIP("127.0.0.1")} + hostAs127001ServingCert, err := caForLocalhostAs127001.IssueServerCert( + noHostnameHost, + only127001IPAddress, + time.Hour, + ) + require.NoError(t, err) + + caForUnknownServer, err := certauthority.New("Some Unknown CA", time.Hour) + require.NoError(t, err) + someUnknownHostNames := []string{"some-dns-name", "some-other-dns-name"} + someLocalIPAddress := []net.IP{net.ParseIP("10.2.3.4")} + pemServerCertForUnknownServer, _, err := caForUnknownServer.IssueServerCertPEM( + someUnknownHostNames, + someLocalIPAddress, + time.Hour, + ) + require.NoError(t, err) + + caForExampleDotCom, err := certauthority.New("Some Example.com CA", time.Hour) + require.NoError(t, err) + exampleDotComHostname := []string{"example.com"} + localButExampleDotComServerCert, err := caForExampleDotCom.IssueServerCert( + exampleDotComHostname, + []net.IP{}, + time.Hour, + ) + require.NoError(t, err) + + hostAsLocalhostMux := http.NewServeMux() + hostAsLocalhostWebhookServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsserver.AssertTLS(t, r, ptls.Default) + hostAsLocalhostMux.ServeHTTP(w, r) + }), func(thisServer *httptest.Server) { + thisTLSConfig := ptls.Default(nil) + thisTLSConfig.Certificates = []tls.Certificate{ + *hostAsLocalhostServingCert, + } + thisServer.TLS = thisTLSConfig + }) + + hostAs127001Mux := http.NewServeMux() + hostAs127001WebhookServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsserver.AssertTLS(t, r, ptls.Default) + hostAs127001Mux.ServeHTTP(w, r) + }), func(thisServer *httptest.Server) { + thisTLSConfig := ptls.Default(nil) + thisTLSConfig.Certificates = []tls.Certificate{ + *hostAs127001ServingCert, + } + thisServer.TLS = thisTLSConfig + }) + + localWithExampleDotComMux := http.NewServeMux() + hostLocalWithExampleDotComCertServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsserver.AssertTLS(t, r, ptls.Default) + localWithExampleDotComMux.ServeHTTP(w, r) + }), func(thisServer *httptest.Server) { + thisTLSConfig := ptls.Default(nil) + thisTLSConfig.Certificates = []tls.Certificate{ + *localButExampleDotComServerCert, + } + thisServer.TLS = thisTLSConfig + }) + + goodMux := http.NewServeMux() + hostGoodDefaultServingCertServer := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tlsserver.AssertTLS(t, r, ptls.Default) + goodMux.ServeHTTP(w, r) + }), tlsserver.RecordTLSHello) + goodMux.Handle("/some/webhook", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, err := fmt.Fprintf(w, `{"something": "%s"}`, "something-for-response") + require.NoError(t, err) + })) + goodMux.Handle("/nothing/here", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, "404 nothing here") + })) + + goodWebhookDefaultServingCertEndpoint := hostGoodDefaultServingCertServer.URL + goodWebhookDefaultServingCertEndpointBut404 := goodWebhookDefaultServingCertEndpoint + "/nothing/here" + + localhostURL, err := url.Parse(hostAsLocalhostWebhookServer.URL) + require.NoError(t, err) + + badEndpointInvalidURL := "https://.café .com/café/café/café/coffee" + badEndpointNoHTTPS := "http://localhost" + + nowDoesntMatter := time.Date(1122, time.September, 33, 4, 55, 56, 778899, time.Local) + frozenMetav1Now := metav1.NewTime(nowDoesntMatter) + frozenClock := clocktesting.NewFakeClock(nowDoesntMatter) + + timeInThePast := time.Date(1111, time.January, 1, 1, 1, 1, 111111, time.Local) + frozenTimeInThePast := metav1.NewTime(timeInThePast) + + goodWebhookAuthenticatorSpecWithCA := auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: goodWebhookDefaultServingCertEndpoint, + TLS: conciergetestutil.TLSSpecFromTLSConfig(hostGoodDefaultServingCertServer.TLS), + } + localWithExampleDotComWeebhookAuthenticatorSpec := auth1alpha1.WebhookAuthenticatorSpec{ + // CA for example.com, TLS serving cert for example.com, but endpoint is still localhost + Endpoint: hostLocalWithExampleDotComCertServer.URL, + TLS: &auth1alpha1.TLSSpec{ + // CA Bundle for example.com + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForExampleDotCom.Bundle()), + }, + } + goodWebhookAuthenticatorSpecWithoutCA := auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: goodWebhookDefaultServingCertEndpoint, + TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: ""}, + } + goodWebhookAuthenticatorSpecWith404Endpoint := auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: goodWebhookDefaultServingCertEndpointBut404, + TLS: conciergetestutil.TLSSpecFromTLSConfig(hostGoodDefaultServingCertServer.TLS), + } + badWebhookAuthenticatorSpecInvalidTLS := auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: goodWebhookDefaultServingCertEndpoint, + TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "invalid base64-encoded data"}, + } + + badWebhookAuthenticatorSpecGoodEndpointButUnknownCA := auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: goodWebhookDefaultServingCertEndpoint, + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemServerCertForUnknownServer), + }, + } + + happyReadyCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "Ready", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "the WebhookAuthenticator is ready", + } + } + sadReadyCondition := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "Ready", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "NotReady", + Message: "the WebhookAuthenticator is not ready: see other conditions for details", + } + } + + happyAuthenticatorValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "AuthenticatorValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "authenticator initialized", + } + } + unknownAuthenticatorValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "AuthenticatorValid", + Status: "Unknown", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + } + } + + happyTLSConfigurationValidCAParsed := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TLSConfigurationValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "successfully parsed specified CA bundle", + } + } + happyTLSConfigurationValidNoCA := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TLSConfigurationValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "no CA bundle specified", + } + } + sadTLSConfigurationValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "TLSConfigurationValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidTLSConfiguration", + Message: "invalid TLS configuration: illegal base64 data at input byte 7", + } + } + + happyWebhookConnectionValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "WebhookConnectionValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "tls verified", + } + } + unknownWebhookConnectionValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "WebhookConnectionValid", + Status: "Unknown", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + } + } + sadWebhookConnectionValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "WebhookConnectionValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToDialServer", + Message: "cannot dial server: tls: failed to verify certificate: x509: certificate signed by unknown authority", + } + } + sadWebhookConnectionValidNoIPSANs := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "WebhookConnectionValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToDialServer", + Message: "cannot dial server: tls: failed to verify certificate: x509: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs", + } + } + sadWebhookConnectionValidWithMessage := func(time metav1.Time, observedGeneration int64, msg string) metav1.Condition { + return metav1.Condition{ + Type: "WebhookConnectionValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "UnableToDialServer", + Message: msg, + } + } + + happyEndpointURLValid := func(time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "EndpointURLValid", + Status: "True", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "Success", + Message: "endpoint is a valid URL", + } + } + sadEndpointURLValid := func(issuer string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "EndpointURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidEndpointURL", + Message: fmt.Sprintf(`spec.endpoint URL cannot be parsed: parse "%s": invalid character " " in host name`, issuer), + } + } + sadEndpointURLValidHTTPS := func(endpoint string, time metav1.Time, observedGeneration int64) metav1.Condition { + return metav1.Condition{ + Type: "EndpointURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidEndpointURLScheme", + Message: fmt.Sprintf(`spec.endpoint URL %s has invalid scheme, require 'https'`, endpoint), + } + } + + sadEndpointURLValidWithMessage := func(time metav1.Time, observedGeneration int64, msg string) metav1.Condition { + return metav1.Condition{ + Type: "EndpointURLValid", + Status: "False", + ObservedGeneration: observedGeneration, + LastTransitionTime: time, + Reason: "InvalidEndpointURL", + Message: msg, + } + } + + allHappyConditionsSuccess := func(endpoint string, someTime metav1.Time, observedGeneration int64) []metav1.Condition { + return conditionstestutil.SortByType([]metav1.Condition{ + happyTLSConfigurationValidCAParsed(someTime, observedGeneration), + happyEndpointURLValid(someTime, observedGeneration), + happyWebhookConnectionValid(someTime, observedGeneration), + happyAuthenticatorValid(someTime, observedGeneration), + happyReadyCondition(someTime, observedGeneration), + }) + } + + webhookAuthenticatorGVR := schema.GroupVersionResource{ + Group: "authentication.concierge.pinniped.dev", + Version: "v1alpha1", + Resource: "webhookauthenticators", + } + webhookAuthenticatorGVK := schema.GroupVersionKind{ + Group: "authentication.concierge.pinniped.dev", + Version: "v1alpha1", + Kind: "WebhookAuthenticator", + } + tests := []struct { - name string - syncKey controllerlib.Key - webhooks []runtime.Object - wantErr string - wantLogs []string + name string + syncKey controllerlib.Key + webhooks []runtime.Object + // for modifying the clients to hack in arbitrary api responses + configClient func(*pinnipedfake.Clientset) + tlsDialerFunc func(network string, addr string, config *tls.Config) (*tls.Conn, error) + wantSyncLoopErr testutil.RequireErrorStringFunc + wantLogs []map[string]any + wantActions func() []coretesting.Action wantCacheEntries int }{ { - name: "not found", + name: "404: WebhookAuthenticator not found will abort sync loop, no status conditions", syncKey: controllerlib.Key{Name: "test-name"}, - wantLogs: []string{ - `webhookcachefiller-controller "level"=0 "msg"="Sync() found that the WebhookAuthenticator does not exist yet or was deleted"`, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "Sync() found that the WebhookAuthenticator does not exist yet or was deleted", + }, }, + wantActions: func() []coretesting.Action { + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + } + }, + wantCacheEntries: 0, }, { - name: "invalid webhook", + name: "Sync: valid and unchanged WebhookAuthenticator: loop will preserve existing status conditions", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + Phase: "Ready", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + } + }, + wantCacheEntries: 1, + }, + { + name: "Sync: changed WebhookAuthenticator: loop will update timestamps only on relevant statuses", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + Generation: 1234, + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 1233), + []metav1.Condition{ + sadReadyCondition(frozenTimeInThePast, 1232), + happyEndpointURLValid(frozenTimeInThePast, 1231), + }, + ), + Phase: "Ready", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + Generation: 1234, + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 1234), + []metav1.Condition{ + happyEndpointURLValid(frozenTimeInThePast, 1234), + }, + ), + Phase: "Ready", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 1, + }, + { + name: "Sync: valid WebhookAuthenticator with CA: will complete sync loop successfully with success conditions and ready phase", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + Phase: "Ready", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 1, + }, + { + name: "Sync: valid WebhookAuthenticator without CA: loop will fail to cache the authenticator, will write failed and unknown status conditions, and will enqueue resync", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithoutCA, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithoutCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + happyTLSConfigurationValidNoCA(frozenMetav1Now, 0), + sadWebhookConnectionValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantSyncLoopErr: testutil.WantExactErrorString(`cannot dial server: tls: failed to verify certificate: x509: certificate signed by unknown authority`), + wantCacheEntries: 0, + }, + { + name: "validateTLS: WebhookAuthenticator with invalid CA will fail sync loop and will report failed and unknown conditions and Error phase, but will not enqueue a resync due to user config error", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: badWebhookAuthenticatorSpecInvalidTLS, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: badWebhookAuthenticatorSpecInvalidTLS, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + sadTLSConfigurationValid(frozenMetav1Now, 0), + unknownWebhookConnectionValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 0, + }, + { + name: "validateEndpoint: parsing error (spec.endpoint URL is invalid) will fail sync loop and will report failed and unknown conditions and Error phase, but will not enqueue a resync due to user config error", syncKey: controllerlib.Key{Name: "test-name"}, webhooks: []runtime.Object{ &auth1alpha1.WebhookAuthenticator{ @@ -54,14 +634,43 @@ func TestController(t *testing.T) { Name: "test-name", }, Spec: auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: "invalid url", + Endpoint: badEndpointInvalidURL, }, }, }, - wantErr: `failed to build webhook config: parse "http://invalid url": invalid character " " in host name`, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: badEndpointInvalidURL, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + happyTLSConfigurationValidNoCA(frozenMetav1Now, 0), + sadEndpointURLValid("https://.café .com/café/café/café/coffee", frozenMetav1Now, 0), + unknownWebhookConnectionValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 0, }, { - name: "valid webhook", + name: "validateEndpoint: parsing error (spec.endpoint URL has invalid scheme, requires https) will fail sync loop, will write failed and unknown status conditions, but will not enqueue a resync due to user config error", syncKey: controllerlib.Key{Name: "test-name"}, webhooks: []runtime.Object{ &auth1alpha1.WebhookAuthenticator{ @@ -69,14 +678,620 @@ func TestController(t *testing.T) { Name: "test-name", }, Spec: auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: "https://example.com", - TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: ""}, + Endpoint: badEndpointNoHTTPS, }, }, }, - wantLogs: []string{ - `webhookcachefiller-controller "level"=0 "msg"="added new webhook authenticator" "endpoint"="https://example.com" "webhook"={"name":"test-name"}`, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: badEndpointNoHTTPS, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + happyTLSConfigurationValidNoCA(frozenMetav1Now, 0), + sadEndpointURLValidHTTPS("http://localhost", frozenMetav1Now, 0), + unknownWebhookConnectionValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } }, + wantCacheEntries: 0, + }, + { + name: "validateEndpoint: should error if endpoint cannot be parsed", + syncKey: controllerlib.Key{Name: "test-name"}, + tlsDialerFunc: func(network string, addr string, config *tls.Config) (*tls.Conn, error) { + return nil, errors.New("IPv6 test fake error") + }, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]:69999/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]:69999/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess("https://[0:0:0:0:0:0:0:1]:69999/some/fake/path", frozenMetav1Now, 0), + []metav1.Condition{ + sadEndpointURLValidWithMessage(frozenMetav1Now, 0, `spec.endpoint URL is not valid: invalid port "69999"`), + sadReadyCondition(frozenMetav1Now, 0), + unknownWebhookConnectionValid(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 0, + }, + { + name: "validateConnection: CA does not validate serving certificate for host, the dialer will error, will fail sync loop, will write failed and unknown status conditions, but will not enqueue a resync due to user config error", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: badWebhookAuthenticatorSpecGoodEndpointButUnknownCA, + }, + }, + wantSyncLoopErr: testutil.WantExactErrorString("cannot dial server: tls: failed to verify certificate: x509: certificate signed by unknown authority"), + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: badWebhookAuthenticatorSpecGoodEndpointButUnknownCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + unknownAuthenticatorValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + sadWebhookConnectionValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 0, + }, + // No unit test for system roots. We don't test the JWTAuthenticator's use of system roots either. + // We would have to find a way to mock out roots by adding a dummy cert in order to test this + // { name: "validateConnection: TLS bundle not provided should use system roots to validate server cert signed by a well-known CA",}, + { + name: "validateConnection: 404 endpoint on a valid server will still validate server certificate, will complete sync loop successfully with success conditions and ready phase", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWith404Endpoint, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpointBut404, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWith404Endpoint, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpointBut404, frozenMetav1Now, 0), + Phase: "Ready", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 1, + }, + { + name: "validateConnection: localhost hostname instead of 127.0.0.1 should still dial correctly as dialer should handle hostnames as well as IPv4", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: fmt.Sprintf("%s:%s", "https://localhost", localhostURL.Port()), + TLS: &auth1alpha1.TLSSpec{ + // CA Bundle for validating the server's certs + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAsHostname.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(fmt.Sprintf("%s:%s", "https://localhost", localhostURL.Port()), frozenMetav1Now, 0), + Phase: "Ready", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": fmt.Sprintf("%s:%s", "https://localhost", localhostURL.Port()), + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + } + }, + wantCacheEntries: 1, + }, + { + name: "validateConnection: IPv6 address with port: should call dialer func with correct arguments", + syncKey: controllerlib.Key{Name: "test-name"}, + tlsDialerFunc: func(network string, addr string, config *tls.Config) (*tls.Conn, error) { + assert.Equal(t, "tcp", network) + assert.Equal(t, "[0:0:0:0:0:0:0:1]:4242", addr) + assert.True(t, caForLocalhostAs127001.Pool().Equal(config.RootCAs)) + assert.Equal(t, uint16(tls.VersionTLS12), config.MinVersion) + + return nil, errors.New("IPv6 test fake error to skip real dial in prod code, this is actually success") + }, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]:4242/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]:4242/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess("https://[0:0:0:0:0:0:0:1]:4242/some/fake/path", frozenMetav1Now, 0), + []metav1.Condition{ + sadWebhookConnectionValidWithMessage(frozenMetav1Now, 0, "cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success"), + sadReadyCondition(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantSyncLoopErr: testutil.WantExactErrorString(`cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success`), + wantCacheEntries: 0, + }, + { + name: "validateConnection: IPv6 address without port: should call dialer func with correct arguments", + syncKey: controllerlib.Key{Name: "test-name"}, + tlsDialerFunc: func(network string, addr string, config *tls.Config) (*tls.Conn, error) { + assert.Equal(t, "tcp", network) + assert.Equal(t, "[0:0:0:0:0:0:0:1]:443", addr, "should add default port when port not provided") + assert.True(t, caForLocalhostAs127001.Pool().Equal(config.RootCAs)) + assert.Equal(t, uint16(tls.VersionTLS12), config.MinVersion) + + return nil, errors.New("IPv6 test fake error to skip real dial in prod code, this is actually success") + }, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://[0:0:0:0:0:0:0:1]/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess("https://[0:0:0:0:0:0:0:1]/some/fake/path", frozenMetav1Now, 0), + []metav1.Condition{ + sadWebhookConnectionValidWithMessage(frozenMetav1Now, 0, "cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success"), + sadReadyCondition(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantSyncLoopErr: testutil.WantExactErrorString(`cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success`), + wantCacheEntries: 0, + }, + { + name: "validateConnection: localhost as IP address 127.0.0.1 should still dial correctly as dialer should handle hostnames as well as IPv4 and IPv6 addresses", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: hostAs127001WebhookServer.URL, + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(hostAs127001WebhookServer.URL, frozenMetav1Now, 0), + Phase: "Ready", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": hostAs127001WebhookServer.URL, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + } + }, + wantCacheEntries: 1, + }, + { + name: "validateConnection: CA for example.com, serving cert for example.com, but endpoint 127.0.0.1 will fail to validate certificate and will fail sync loop and will report failed and unknown conditions and Error phase, but will not enqueue a resync due to user config error", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: localWithExampleDotComWeebhookAuthenticatorSpec, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(hostLocalWithExampleDotComCertServer.URL, frozenMetav1Now, 0), + Phase: "Ready", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: localWithExampleDotComWeebhookAuthenticatorSpec, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(hostLocalWithExampleDotComCertServer.URL, frozenMetav1Now, 0), + []metav1.Condition{ + sadWebhookConnectionValidNoIPSANs(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 0, + wantSyncLoopErr: testutil.WantExactErrorString(`cannot dial server: tls: failed to verify certificate: x509: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs`), + }, + { + name: "validateConnection: IPv6 address without port or brackets: should succeed since IPv6 brackets are optional without port", + syncKey: controllerlib.Key{Name: "test-name"}, + tlsDialerFunc: func(network string, addr string, config *tls.Config) (*tls.Conn, error) { + assert.Equal(t, "tcp", network) + assert.Equal(t, "[0:0:0:0:0:0:0:1]:443", addr) + assert.True(t, caForLocalhostAs127001.Pool().Equal(config.RootCAs)) + assert.Equal(t, uint16(tls.VersionTLS12), config.MinVersion) + + return nil, errors.New("IPv6 test fake error to skip real dial in prod code, this is actually success") + }, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://0:0:0:0:0:0:0:1/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: auth1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://0:0:0:0:0:0:0:1/some/fake/path", + TLS: &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(caForLocalhostAs127001.Bundle()), + }, + }, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess("https://0:0:0:0:0:0:0:1/some/fake/path", frozenMetav1Now, 0), + []metav1.Condition{ + sadWebhookConnectionValidWithMessage(frozenMetav1Now, 0, "cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success"), + sadReadyCondition(frozenMetav1Now, 0), + unknownAuthenticatorValid(frozenMetav1Now, 0), + }, + ), + Phase: "Error", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantSyncLoopErr: testutil.WantExactErrorString(`cannot dial server: IPv6 test fake error to skip real dial in prod code, this is actually success`), + wantCacheEntries: 0, + }, + { + name: "updateStatus: called with matching original and updated conditions: will not make request to update conditions", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + Phase: "Ready", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + } + }, + wantCacheEntries: 1, + }, + { + name: "updateStatus: called with different original and updated conditions: will make request to update conditions", + syncKey: controllerlib.Key{Name: "test-name"}, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "SomethingBeforeUpdating", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + Phase: "Ready", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantCacheEntries: 1, + }, + { + name: "updateStatus: when update request fails: error will enqueue a resync", + syncKey: controllerlib.Key{Name: "test-name"}, + configClient: func(client *pinnipedfake.Clientset) { + client.PrependReactor( + "update", + "webhookauthenticators", + func(action coretesting.Action) (handled bool, ret runtime.Object, err error) { + return true, nil, errors.New("some update error") + }, + ) + }, + webhooks: []runtime.Object{ + &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: conditionstestutil.Replace( + allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + []metav1.Condition{ + sadReadyCondition(frozenMetav1Now, 0), + }, + ), + Phase: "SomethingBeforeUpdating", + }, + }, + }, + wantLogs: []map[string]any{ + { + "level": "info", + "timestamp": "2099-08-08T13:57:36.123456Z", + "logger": "webhookcachefiller-controller", + "message": "added new webhook authenticator", + "endpoint": goodWebhookDefaultServingCertEndpoint, + "webhook": map[string]interface{}{ + "name": "test-name", + }, + }, + }, + wantActions: func() []coretesting.Action { + updateStatusAction := coretesting.NewUpdateAction(webhookAuthenticatorGVR, "", &auth1alpha1.WebhookAuthenticator{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-name", + }, + Spec: goodWebhookAuthenticatorSpecWithCA, + Status: auth1alpha1.WebhookAuthenticatorStatus{ + Conditions: allHappyConditionsSuccess(goodWebhookDefaultServingCertEndpoint, frozenMetav1Now, 0), + Phase: "Ready", + }, + }) + updateStatusAction.Subresource = "status" + return []coretesting.Action{ + coretesting.NewListAction(webhookAuthenticatorGVR, webhookAuthenticatorGVK, "", metav1.ListOptions{}), + coretesting.NewWatchAction(webhookAuthenticatorGVR, "", metav1.ListOptions{}), + updateStatusAction, + } + }, + wantSyncLoopErr: testutil.WantExactErrorString("some update error"), wantCacheEntries: 1, }, } @@ -85,12 +1300,26 @@ func TestController(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - fakeClient := pinnipedfake.NewSimpleClientset(tt.webhooks...) - informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0) + pinnipedAPIClient := pinnipedfake.NewSimpleClientset(tt.webhooks...) + if tt.configClient != nil { + tt.configClient(pinnipedAPIClient) + } + informers := pinnipedinformers.NewSharedInformerFactory(pinnipedAPIClient, 0) cache := authncache.New() - testLog := testlogger.NewLegacy(t) //nolint:staticcheck // old test with lots of log statements - controller := New(cache, informers.Authentication().V1alpha1().WebhookAuthenticators(), testLog.Logger) + var log bytes.Buffer + logger := plog.TestLogger(t, &log) + + if tt.tlsDialerFunc == nil { + tt.tlsDialerFunc = tls.Dial + } + controller := New( + cache, + pinnipedAPIClient, + informers.Authentication().V1alpha1().WebhookAuthenticators(), + frozenClock, + logger, + tt.tlsDialerFunc) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -100,79 +1329,188 @@ func TestController(t *testing.T) { syncCtx := controllerlib.Context{Context: ctx, Key: tt.syncKey} - if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" { - require.EqualError(t, err, tt.wantErr) + if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantSyncLoopErr != nil { + testutil.RequireErrorStringFromErr(t, err, tt.wantSyncLoopErr) } else { require.NoError(t, err) } - require.Equal(t, tt.wantLogs, testLog.Lines()) - require.Equal(t, tt.wantCacheEntries, len(cache.Keys())) + actualLogLines := testutil.SplitByNewline(log.String()) + require.Equal(t, len(tt.wantLogs), len(actualLogLines), "log line count should be correct") + + for logLineNum, logLine := range actualLogLines { + require.NotNil(t, tt.wantLogs[logLineNum], "expected log line should never be empty") + var lineStruct map[string]any + err := json.Unmarshal([]byte(logLine), &lineStruct) + require.NoError(t, err) + require.Equal(t, tt.wantLogs[logLineNum]["level"], lineStruct["level"], fmt.Sprintf("log line (%d) log level should be correct (in: %s)", logLineNum, lineStruct)) + + require.Equal(t, tt.wantLogs[logLineNum]["timestamp"], lineStruct["timestamp"], fmt.Sprintf("log line (%d) timestamp should be correct (in: %s)", logLineNum, lineStruct)) + require.Equal(t, lineStruct["logger"], tt.wantLogs[logLineNum]["logger"], fmt.Sprintf("log line (%d) logger should be correct", logLineNum)) + require.NotEmpty(t, lineStruct["caller"], fmt.Sprintf("log line (%d) caller should not be empty", logLineNum)) + require.Equal(t, tt.wantLogs[logLineNum]["message"], lineStruct["message"], fmt.Sprintf("log line (%d) message should be correct", logLineNum)) + if lineStruct["webhook"] != nil { + require.Equal(t, tt.wantLogs[logLineNum]["webhook"], lineStruct["webhook"], fmt.Sprintf("log line (%d) webhook should be correct", logLineNum)) + } + if lineStruct["endpoint"] != nil { + require.Equal(t, tt.wantLogs[logLineNum]["endpoint"], lineStruct["endpoint"], fmt.Sprintf("log line (%d) endpoint should be correct", logLineNum)) + } + } + + if tt.wantActions != nil { + if !assert.ElementsMatch(t, tt.wantActions(), pinnipedAPIClient.Actions()) { + // cmp.Diff is superior to require.ElementsMatch in terms of readability here. + // require.ElementsMatch will handle pointers better than require.Equal, but + // the timestamps are still incredibly verbose. + require.Fail(t, cmp.Diff(tt.wantActions(), pinnipedAPIClient.Actions()), "actions should be exactly the expected number of actions and also contain the correct resources") + } + } else { + require.Fail(t, "wantActions is required for test "+tt.name) + } + + require.Equal(t, tt.wantCacheEntries, len(cache.Keys()), fmt.Sprintf("expected cache entries is incorrect. wanted:%d, got: %d, keys: %v", tt.wantCacheEntries, len(cache.Keys()), cache.Keys())) }) } } func TestNewWebhookAuthenticator(t *testing.T) { - t.Run("temp file failure", func(t *testing.T) { - brokenTempFile := func(_ string, _ string) (*os.File, error) { return nil, fmt.Errorf("some temp file error") } - res, err := newWebhookAuthenticator(nil, brokenTempFile, clientcmd.WriteToFile) - require.Nil(t, res) - require.EqualError(t, err, "unable to create temporary file: some temp file error") - }) + goodEndpoint := "https://example.com" - t.Run("marshal failure", func(t *testing.T) { - marshalError := func(_ clientcmdapi.Config, _ string) error { return fmt.Errorf("some marshal error") } - res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{}, os.CreateTemp, marshalError) - require.Nil(t, res) - require.EqualError(t, err, "unable to marshal kubeconfig: some marshal error") - }) - - t.Run("invalid base64", func(t *testing.T) { - res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: "https://example.com", - TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "invalid-base64"}, - }, os.CreateTemp, clientcmd.WriteToFile) - require.Nil(t, res) - require.EqualError(t, err, "invalid TLS configuration: illegal base64 data at input byte 7") - }) - - t.Run("invalid pem data", func(t *testing.T) { - res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: "https://example.com", - TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte("bad data"))}, - }, os.CreateTemp, clientcmd.WriteToFile) - require.Nil(t, res) - require.EqualError(t, err, "invalid TLS configuration: certificateAuthorityData is not valid PEM: data does not contain any valid RSA or ECDSA certificates") - }) - - t.Run("valid config with no TLS spec", func(t *testing.T) { - res, err := newWebhookAuthenticator(&auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: "https://example.com", - }, os.CreateTemp, clientcmd.WriteToFile) - require.NotNil(t, res) + testServerCABundle, testServerURL := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.Contains(t, string(body), "test-token") + _, err = w.Write([]byte(`{}`)) require.NoError(t, err) }) - t.Run("success", func(t *testing.T) { - caBundle, url := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - require.NoError(t, err) - require.Contains(t, string(body), "test-token") - _, err = w.Write([]byte(`{}`)) - require.NoError(t, err) - }) - spec := &auth1alpha1.WebhookAuthenticatorSpec{ - Endpoint: url, - TLS: &auth1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(caBundle)), + tests := []struct { + name string + endpoint string + pemBytes []byte + tempFileFunc func(dir string, pattern string) (*os.File, error) + marshallFunc func(config clientcmdapi.Config, filename string) error + prereqOk bool + wantConditions []*metav1.Condition + wantWebhook bool + wantErr string + testCreatedWebhookWithFakeToken bool + }{ + { + name: "prerequisites not ready, cannot create webhook authenticator", + endpoint: "", + pemBytes: []byte("irrelevant pem bytes"), + tempFileFunc: os.CreateTemp, + marshallFunc: clientcmd.WriteToFile, + wantErr: "", + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "Unknown", + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + }}, + prereqOk: false, + }, { + name: "temp file failure, cannot create webhook authenticator", + endpoint: "", + pemBytes: []byte("irrelevant pem bytes"), + tempFileFunc: func(_ string, _ string) (*os.File, error) { + return nil, fmt.Errorf("some temp file error") }, - } - res, err := newWebhookAuthenticator(spec, os.CreateTemp, clientcmd.WriteToFile) - require.NoError(t, err) - require.NotNil(t, res) + marshallFunc: clientcmd.WriteToFile, + prereqOk: true, + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "False", + Reason: "UnableToCreateTempFile", + Message: "unable to create temporary file: some temp file error", + }}, + wantErr: "unable to create temporary file: some temp file error", + }, { + name: "marshal failure, cannot create webhook authenticator", + endpoint: "", + pemBytes: []byte("irrelevant pem bytes"), + tempFileFunc: os.CreateTemp, + marshallFunc: func(_ clientcmdapi.Config, _ string) error { + return fmt.Errorf("some marshal error") + }, + prereqOk: true, + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "False", + Reason: "UnableToMarshallKubeconfig", + Message: "unable to marshal kubeconfig: some marshal error", + }}, + wantErr: "unable to marshal kubeconfig: some marshal error", + }, { + name: "invalid pem data, unable to parse bytes as PEM block", + endpoint: goodEndpoint, + pemBytes: []byte("invalid-bas64"), + tempFileFunc: os.CreateTemp, + marshallFunc: clientcmd.WriteToFile, + prereqOk: true, + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "False", + Reason: "UnableToInstantiateWebhook", + Message: "unable to instantiate webhook: unable to load root certificates: unable to parse bytes as PEM block", + }}, + wantErr: "unable to instantiate webhook: unable to load root certificates: unable to parse bytes as PEM block", + }, { + name: "valid config with no TLS spec, webhook authenticator created", + endpoint: goodEndpoint, + pemBytes: nil, + tempFileFunc: os.CreateTemp, + marshallFunc: clientcmd.WriteToFile, + prereqOk: true, + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "True", + Reason: "Success", + Message: "authenticator initialized", + }}, + wantWebhook: true, + }, { + name: "success, webhook authenticator created", + endpoint: testServerURL, + pemBytes: []byte(testServerCABundle), + tempFileFunc: os.CreateTemp, + marshallFunc: clientcmd.WriteToFile, + prereqOk: true, + wantConditions: []*metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "True", + Reason: "Success", + Message: "authenticator initialized", + }}, + testCreatedWebhookWithFakeToken: true, + }, + } - resp, authenticated, err := res.AuthenticateToken(context.Background(), "test-token") - require.NoError(t, err) - require.Nil(t, resp) - require.False(t, authenticated) - }) + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + var conditions []*metav1.Condition + webhook, conditions, err := newWebhookAuthenticator(tt.endpoint, tt.pemBytes, tt.tempFileFunc, tt.marshallFunc, conditions, tt.prereqOk) + + require.Equal(t, tt.wantConditions, conditions) + + if tt.wantWebhook { + require.NotNil(t, webhook) + } + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + + if tt.testCreatedWebhookWithFakeToken { + authResp, isAuthenticated, err := webhook.AuthenticateToken(context.Background(), "test-token") + require.NoError(t, err) + require.Nil(t, authResp) + require.False(t, isAuthenticated) + } + }) + } } diff --git a/internal/controller/conditionsutil/conditions_util.go b/internal/controller/conditionsutil/conditions_util.go index fa0cd8114..7412db764 100644 --- a/internal/controller/conditionsutil/conditions_util.go +++ b/internal/controller/conditionsutil/conditions_util.go @@ -66,7 +66,7 @@ func mergeIDPCondition(existing *[]metav1.Condition, new *metav1.Condition) bool return false } -// MergeConfigConditions merges conditions into conditionsToUpdate. If returns true if it merged any error conditions. +// MergeConfigConditions merges conditions into conditionsToUpdate. It returns true if it merged any error conditions. func MergeConfigConditions(conditions []*metav1.Condition, observedGeneration int64, conditionsToUpdate *[]metav1.Condition, log plog.MinLogger, now metav1.Time) bool { hadErrorCondition := false for i := range conditions { diff --git a/internal/controller/kubecertagent/kubecertagent_test.go b/internal/controller/kubecertagent/kubecertagent_test.go index f8fb5482d..2756e743b 100644 --- a/internal/controller/kubecertagent/kubecertagent_test.go +++ b/internal/controller/kubecertagent/kubecertagent_test.go @@ -7,7 +7,6 @@ import ( "bytes" "context" "fmt" - "strings" "testing" "time" @@ -1085,7 +1084,7 @@ func TestAgentController(t *testing.T) { allAllowedErrors = append(allAllowedErrors, tt.alsoAllowUndesiredDistinctErrors...) assert.Subsetf(t, allAllowedErrors, actualErrors, "actual errors contained additional error(s) which is not expected by the test") - assert.Equal(t, tt.wantDistinctLogs, deduplicate(logLines(buf.String())), "unexpected logs") + assert.Equal(t, tt.wantDistinctLogs, deduplicate(testutil.SplitByNewline(buf.String())), "unexpected logs") // Assert on all actions that happened to deployments. var actualDeploymentActionVerbs []string @@ -1128,14 +1127,6 @@ func TestAgentController(t *testing.T) { } } -func logLines(logs string) []string { - if len(logs) == 0 { - return nil - } - - return strings.Split(strings.TrimSpace(logs), "\n") -} - func TestMergeLabelsAndAnnotations(t *testing.T) { t.Parallel() diff --git a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go index 9edf172f7..396df85dc 100644 --- a/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go +++ b/internal/controller/supervisorconfig/oidcupstreamwatcher/oidc_upstream_watcher.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package oidcupstreamwatcher implements a controller which watches OIDCIdentityProviders. @@ -342,7 +342,7 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1 Type: typeOIDCDiscoverySucceeded, Status: metav1.ConditionFalse, Reason: reasonUnreachable, - Message: fmt.Sprintf("failed to perform OIDC discovery against %q:\n%s", upstream.Spec.Issuer, truncateMostLongErr(err)), + Message: fmt.Sprintf("failed to perform OIDC discovery against %q:\n%s", upstream.Spec.Issuer, pinnipedcontroller.TruncateMostLongErr(err)), } } @@ -361,7 +361,7 @@ func (c *oidcWatcherController) validateIssuer(ctx context.Context, upstream *v1 Type: typeOIDCDiscoverySucceeded, Status: metav1.ConditionFalse, Reason: reasonInvalidResponse, - Message: fmt.Sprintf("failed to unmarshal OIDC discovery response from %q:\n%s", upstream.Spec.Issuer, truncateMostLongErr(err)), + Message: fmt.Sprintf("failed to unmarshal OIDC discovery response from %q:\n%s", upstream.Spec.Issuer, pinnipedcontroller.TruncateMostLongErr(err)), } } if additionalDiscoveryClaims.RevocationEndpoint != "" { @@ -473,18 +473,6 @@ func computeScopes(additionalScopes []string) []string { return set.List() } -func truncateMostLongErr(err error) string { - const max = 300 - msg := err.Error() - - // always log oidc and x509 errors completely - if len(msg) <= max || strings.Contains(msg, "oidc:") || strings.Contains(msg, "x509:") { - return msg - } - - return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max) -} - func validateHTTPSURL(maybeHTTPSURL, endpointType, reason string) (*url.URL, *metav1.Condition) { parsedURL, err := url.Parse(maybeHTTPSURL) if err != nil { @@ -492,7 +480,7 @@ func validateHTTPSURL(maybeHTTPSURL, endpointType, reason string) (*url.URL, *me Type: typeOIDCDiscoverySucceeded, Status: metav1.ConditionFalse, Reason: reason, - Message: fmt.Sprintf("failed to parse %s URL: %v", endpointType, truncateMostLongErr(err)), + Message: fmt.Sprintf("failed to parse %s URL: %v", endpointType, pinnipedcontroller.TruncateMostLongErr(err)), } } if parsedURL.Scheme != "https" { diff --git a/internal/controller/utilserr.go b/internal/controller/utilserr.go new file mode 100644 index 000000000..485b2f647 --- /dev/null +++ b/internal/controller/utilserr.go @@ -0,0 +1,21 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "fmt" + "strings" +) + +func TruncateMostLongErr(err error) string { + const max = 300 + msg := err.Error() + + // always log oidc and x509 errors completely + if len(msg) <= max || strings.Contains(msg, "oidc:") || strings.Contains(msg, "x509:") { + return msg + } + + return msg[:max] + fmt.Sprintf(" [truncated %d chars]", len(msg)-max) +} diff --git a/internal/controllermanager/prepare_controllers.go b/internal/controllermanager/prepare_controllers.go index 1a8e195d6..3a65c7caf 100644 --- a/internal/controllermanager/prepare_controllers.go +++ b/internal/controllermanager/prepare_controllers.go @@ -6,6 +6,7 @@ package controllermanager import ( + "crypto/tls" "fmt" "time" @@ -236,8 +237,11 @@ func PrepareControllers(c *Config) (controllerinit.RunnerBuilder, error) { //nol WithController( webhookcachefiller.New( c.AuthenticatorCache, + client.PinnipedConcierge, informers.pinniped.Authentication().V1alpha1().WebhookAuthenticators(), - plog.Logr(), //nolint:staticcheck // old controller with lots of log statements + clock.RealClock{}, + plog.New(), + tls.Dial, ), singletonWorker, ). diff --git a/internal/endpointaddr/endpointaddr.go b/internal/endpointaddr/endpointaddr.go index fd9277658..c6d0223af 100644 --- a/internal/endpointaddr/endpointaddr.go +++ b/internal/endpointaddr/endpointaddr.go @@ -1,4 +1,4 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package endpointaddr implements parsing and validation of "[:]" strings for Pinniped APIs. @@ -7,7 +7,9 @@ package endpointaddr import ( "fmt" "net" + "net/url" "strconv" + "strings" "k8s.io/apimachinery/pkg/util/validation" ) @@ -38,7 +40,7 @@ func (h *HostPort) Endpoint() string { // - ":" (IPv4 address with port) // - "[]:" (IPv6 address with port, brackets are required) // -// If the input does not not specify a port number, then defaultPort will be used. +// If the input does not specify a port number, then defaultPort will be used. func Parse(endpoint string, defaultPort uint16) (HostPort, error) { // Try parsing it both with and without an implicit port 443 at the end. host, port, err := net.SplitHostPort(endpoint) @@ -69,3 +71,28 @@ func Parse(endpoint string, defaultPort uint16) (HostPort, error) { return HostPort{Host: host, Port: uint16(integerPort)}, nil } + +// ParseFromURL wraps Parse but specifically takes a url.URL instead of an endpoint string. +// ParseFromURL differs from Parse in that IPv6 addresses must be wrapped in brackets +// when used in a URL (even when used without a port). +// +// If the input does not specify a port number, then defaultPort will be used. +// +// The RFC for literal IPv6 addresses in URLs indicates that brackets +// - must be used when a port is provided +// - should be used when a port is not provided, but does not indicate "must" +// See https://datatracker.ietf.org/doc/html/rfc2732#section-2 +// +// However, the Golang docs make it clear that IPv6 addresses must be wrapped +// in brackets when used in a URL. +// See https://pkg.go.dev/net/url#URL +// +// Note that ParseFromURL returns a HostPort which has an Endpoint() method which +// will return a properly constructed URL with brackets when appropriate. +func ParseFromURL(u *url.URL, defaultPort uint16) (HostPort, error) { + host := u.Host + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + } + return Parse(host, defaultPort) +} diff --git a/internal/endpointaddr/endpointaddr_test.go b/internal/endpointaddr/endpointaddr_test.go index 736df3121..adf6bb0b7 100644 --- a/internal/endpointaddr/endpointaddr_test.go +++ b/internal/endpointaddr/endpointaddr_test.go @@ -1,12 +1,14 @@ -// Copyright 2021 the Pinniped contributors. All Rights Reserved. +// Copyright 2021-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package endpointaddr import ( + "net/url" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestParse(t *testing.T) { @@ -180,3 +182,170 @@ func TestParse(t *testing.T) { }) } } + +func TestParseFromURL(t *testing.T) { + t.Parallel() + for _, tt := range []struct { + name string + input string + defaultPort uint16 + expectErr string + expect HostPort + // HostPort.Endpoint() returns a properly constructed endpoint. The normalization provided by ParseFromURL() + // expects that the resulting HostPort.Endpoint() will be called to normalize several special cases, especially + // for IPv6. + expectEndpoint string + }{ + // First set of valid passthrough tests to Parse() + // Matches the above test table, minus any test that would not url.Parse(input) properly + { + name: "plain IPv4", + input: "http://127.0.0.1", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 443}, + expectEndpoint: "127.0.0.1:443", + }, + { + name: "IPv4 with port", + input: "http://127.0.0.1:8443", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 8443}, + expectEndpoint: "127.0.0.1:8443", + }, + { + name: "IPv4 in brackets with port", + input: "http://[127.0.0.1]:8443", + defaultPort: 443, + expect: HostPort{Host: "127.0.0.1", Port: 8443}, + expectEndpoint: "127.0.0.1:8443", + }, + { + name: "IPv4 as IPv6 in brackets with port", + input: "http://[::127.0.0.1]:8443", + defaultPort: 443, + expect: HostPort{Host: "::127.0.0.1", Port: 8443}, + expectEndpoint: "[::127.0.0.1]:8443", + }, + { + name: "IPv6 with port", + input: "http://[2001:db8::ffff]:8443", + defaultPort: 443, + expect: HostPort{Host: "2001:db8::ffff", Port: 8443}, + expectEndpoint: "[2001:db8::ffff]:8443", + }, + { + name: "plain hostname", + input: "http://host.example.com", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 443}, + expectEndpoint: "host.example.com:443", + }, + { + name: "plain hostname with dash", + input: "http://host-dev.example.com", + defaultPort: 443, + expect: HostPort{Host: "host-dev.example.com", Port: 443}, + expectEndpoint: "host-dev.example.com:443", + }, + { + name: "hostname with port", + input: "http://host.example.com:8443", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 8443}, + expectEndpoint: "host.example.com:8443", + }, + { + name: "hostname in brackets with port", + input: "http://[host.example.com]:8443", + defaultPort: 443, + expect: HostPort{Host: "host.example.com", Port: 8443}, + expectEndpoint: "host.example.com:8443", + }, + { + name: "hostname without dots", + input: "http://localhost", + defaultPort: 443, + expect: HostPort{Host: "localhost", Port: 443}, + expectEndpoint: "localhost:443", + }, + { + name: "hostname and port without dots", + input: "http://localhost:8443", + defaultPort: 443, + expect: HostPort{Host: "localhost", Port: 8443}, + expectEndpoint: "localhost:8443", + }, + { + name: "http://invalid empty string", + input: "", + defaultPort: 443, + expectErr: `host "" is not a valid hostname or IP address`, + }, + { + name: "invalid host with underscores", + input: "http://___.example.com:1234", + defaultPort: 443, + expectErr: `host "___.example.com" is not a valid hostname or IP address`, + }, + { + name: "invalid host with uppercase", + input: "http://HOST.EXAMPLE.COM", + defaultPort: 443, + expectErr: `host "HOST.EXAMPLE.COM" is not a valid hostname or IP address`, + }, + // new tests for new functionality + { + name: "IPv6 with brackets but without port will strip brackets to create HostPort{}, which will add brackets when HostPort.Endpoint() is called", + input: "http://[2001:db8::ffff]", + defaultPort: 443, + expect: HostPort{Host: "2001:db8::ffff", Port: 443}, + expectEndpoint: "[2001:db8::ffff]:443", + }, + { + name: "IPv6 without brackets and without port will create HostPort{}, which will add brackets when HostPort.Endpoint() is called", + input: "http://2001:db8::1234", + defaultPort: 443, + expect: HostPort{Host: "2001:db8::1234", Port: 443}, + expectEndpoint: "[2001:db8::1234]:443", + }, + { + name: "IPv6 without brackets and without port with path create HostPort{}, which will add brackets when HostPort.Endpoint() is called", + input: "https://0:0:0:0:0:0:0:1/some/fake/path", + defaultPort: 443, + expect: HostPort{Host: "0:0:0:0:0:0:0:1", Port: 443}, + expectEndpoint: "[0:0:0:0:0:0:0:1]:443", + }, + { + name: "IPv6 with mismatched leading bracket will err on bracket", + input: "https://[[::1]/some/fake/path", + defaultPort: 443, + expect: HostPort{Host: "[[::1]", Port: 443}, + expectEndpoint: "[[::1]:443", + expectErr: `address [[::1]:443: unexpected '[' in address`, + }, + { + name: "IPv6 with mismatched trailing brackets will err on port", + input: "https://[::1]]/some/fake/path", + defaultPort: 443, + expect: HostPort{Host: "[::1]]", Port: 443}, + expectEndpoint: "[::1]]:443", + expectErr: `address [::1]]:443: missing port in address`, + }, + } { + tt := tt + t.Run(tt.name, func(t *testing.T) { + urlToProcess, err := url.Parse(tt.input) + require.NoError(t, err, "ParseFromURL expects a valid url.URL, parse errors here are not valuable") + + got, err := ParseFromURL(urlToProcess, tt.defaultPort) + if tt.expectErr == "" { + assert.NoError(t, err) + assert.Equal(t, tt.expect, got) + assert.Equal(t, tt.expectEndpoint, got.Endpoint()) + } else { + assert.EqualError(t, err, tt.expectErr) + assert.Equal(t, HostPort{}, got) + } + }) + } +} diff --git a/internal/testutil/conciergetestutil/tlstestutil.go b/internal/testutil/conciergetestutil/tlstestutil.go new file mode 100644 index 000000000..f99ba4e86 --- /dev/null +++ b/internal/testutil/conciergetestutil/tlstestutil.go @@ -0,0 +1,28 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package conciergetestutil + +import ( + "crypto/tls" + "encoding/base64" + "encoding/pem" + + auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" +) + +func TLSSpecFromTLSConfig(tls *tls.Config) *auth1alpha1.TLSSpec { + pemData := make([]byte, 0) + for _, certificate := range tls.Certificates { + // this is the public part of the certificate, the private is the certificate.PrivateKey + for _, reallyCertificate := range certificate.Certificate { + pemData = append(pemData, pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: reallyCertificate, + })...) + } + } + return &auth1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemData), + } +} diff --git a/internal/testutil/stringutil.go b/internal/testutil/stringutil.go new file mode 100644 index 000000000..35902de60 --- /dev/null +++ b/internal/testutil/stringutil.go @@ -0,0 +1,14 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import "strings" + +func SplitByNewline(lineToSplit string) []string { + if len(lineToSplit) == 0 { + return nil + } + + return strings.Split(strings.TrimSpace(lineToSplit), "\n") +} diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 968b7d065..0da362f66 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -26,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" conciergescheme "go.pinniped.dev/internal/concierge/scheme" "go.pinniped.dev/pkg/oidcclient" @@ -42,7 +43,7 @@ func TestCLIGetKubeconfigStaticToken_Parallel(t *testing.T) { ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute) defer cancelFunc() - authenticator := testlib.CreateTestWebhookAuthenticator(ctx, t) + authenticator := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, v1alpha1.WebhookAuthenticatorPhaseReady) // Build pinniped CLI. pinnipedExe := testlib.PinnipedCLIPath(t) diff --git a/test/integration/concierge_api_serving_certs_test.go b/test/integration/concierge_api_serving_certs_test.go index 1162d03c8..d5350c056 100644 --- a/test/integration/concierge_api_serving_certs_test.go +++ b/test/integration/concierge_api_serving_certs_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -12,6 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" + "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/test/testlib" @@ -83,7 +84,7 @@ func TestAPIServingCertificateAutoCreationAndRotation_Disruptive(t *testing.T) { // Create a testWebhook so we have a legitimate authenticator to pass to the // TokenCredentialRequest API. - testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t) + testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, v1alpha1.WebhookAuthenticatorPhaseReady) // Get the initial auto-generated version of the Secret. secret, err := kubeClient.CoreV1().Secrets(env.ConciergeNamespace).Get(ctx, defaultServingCertResourceName, metav1.GetOptions{}) diff --git a/test/integration/concierge_client_test.go b/test/integration/concierge_client_test.go index 8a36f7f44..6a7ffae76 100644 --- a/test/integration/concierge_client_test.go +++ b/test/integration/concierge_client_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2022 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" + "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" "go.pinniped.dev/internal/here" "go.pinniped.dev/pkg/conciergeclient" "go.pinniped.dev/test/testlib" @@ -58,7 +59,7 @@ func TestClient(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - webhook := testlib.CreateTestWebhookAuthenticator(ctx, t) + webhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, v1alpha1.WebhookAuthenticatorPhaseReady) // Use an invalid certificate/key to validate that the ServerVersion API fails like we assume. invalidClient := testlib.NewClientsetWithCertAndKey(t, testCert, testKey) diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index f05c93134..cadf16de9 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -60,8 +60,10 @@ func TestSuccessfulCredentialRequest_Browser(t *testing.T) { token func(t *testing.T) (token string, username string, groups []string) }{ { - name: "webhook", - authenticator: testlib.CreateTestWebhookAuthenticator, + name: "webhook", + authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { + return testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, auth1alpha1.WebhookAuthenticatorPhaseReady) + }, token: func(t *testing.T) (string, string, []string) { return testlib.IntegrationEnv(t).TestUser.Token, env.TestUser.ExpectedUsername, env.TestUser.ExpectedGroups }, @@ -148,7 +150,7 @@ func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthentic // TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t) + testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, auth1alpha1.WebhookAuthenticatorPhaseReady) response, err := testlib.CreateTokenCredentialRequest(context.Background(), t, loginv1alpha1.TokenCredentialRequestSpec{Token: "not a good token", Authenticator: testWebhook}, @@ -169,7 +171,7 @@ func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken_Parallel(t * // TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t) + testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, auth1alpha1.WebhookAuthenticatorPhaseReady) response, err := testlib.CreateTokenCredentialRequest(context.Background(), t, loginv1alpha1.TokenCredentialRequestSpec{Token: "", Authenticator: testWebhook}, diff --git a/test/integration/concierge_impersonation_proxy_test.go b/test/integration/concierge_impersonation_proxy_test.go index 8df6ad238..38ebb23ce 100644 --- a/test/integration/concierge_impersonation_proxy_test.go +++ b/test/integration/concierge_impersonation_proxy_test.go @@ -61,6 +61,7 @@ import ( "k8s.io/client-go/util/retry" "k8s.io/utils/ptr" + "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" conciergev1alpha "go.pinniped.dev/generated/latest/apis/concierge/config/v1alpha1" identityv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/identity/v1alpha1" loginv1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" @@ -120,7 +121,7 @@ func TestImpersonationProxy(t *testing.T) { //nolint:gocyclo // yeah, it's compl // Create a WebhookAuthenticator and prepare a TokenCredentialRequestSpec using the authenticator for use later. credentialRequestSpecWithWorkingCredentials := loginv1alpha1.TokenCredentialRequestSpec{ Token: env.TestUser.Token, - Authenticator: testlib.CreateTestWebhookAuthenticator(ctx, t), + Authenticator: testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, v1alpha1.WebhookAuthenticatorPhaseReady), } // The address of the ClusterIP service that points at the impersonation proxy's port (used when there is no load balancer). diff --git a/test/integration/concierge_webhookauthenticator_status_test.go b/test/integration/concierge_webhookauthenticator_status_test.go new file mode 100644 index 000000000..1b372c39e --- /dev/null +++ b/test/integration/concierge_webhookauthenticator_status_test.go @@ -0,0 +1,300 @@ +// Copyright 2024 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" + "go.pinniped.dev/test/testlib" +) + +func TestConciergeWebhookAuthenticatorStatus_Parallel(t *testing.T) { + testEnv := testlib.IntegrationEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + + caBundleSomePivotalCA := "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURVVENDQWptZ0F3SUJBZ0lWQUpzNStTbVRtaTJXeUI0bGJJRXBXaUs5a1RkUE1BMEdDU3FHU0liM0RRRUIKQ3dVQU1COHhDekFKQmdOVkJBWVRBbFZUTVJBd0RnWURWUVFLREFkUWFYWnZkR0ZzTUI0WERUSXdNRFV3TkRFMgpNamMxT0ZvWERUSTBNRFV3TlRFMk1qYzFPRm93SHpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBb01CMUJwCmRtOTBZV3d3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRERZWmZvWGR4Z2NXTEMKZEJtbHB5a0tBaG9JMlBuUWtsVFNXMno1cGcwaXJjOGFRL1E3MXZzMTRZYStmdWtFTGlvOTRZYWw4R01DdVFrbApMZ3AvUEE5N1VYelhQNDBpK25iNXcwRGpwWWd2dU9KQXJXMno2MFRnWE5NSFh3VHk4ME1SZEhpUFVWZ0VZd0JpCmtkNThzdEFVS1Y1MnBQTU1reTJjNy9BcFhJNmRXR2xjalUvaFBsNmtpRzZ5dEw2REtGYjJQRWV3MmdJM3pHZ2IKOFVVbnA1V05DZDd2WjNVY0ZHNXlsZEd3aGc3cnZ4U1ZLWi9WOEhCMGJmbjlxamlrSVcxWFM4dzdpUUNlQmdQMApYZWhKZmVITlZJaTJtZlczNlVQbWpMdnVKaGpqNDIrdFBQWndvdDkzdWtlcEgvbWpHcFJEVm9wamJyWGlpTUYrCkYxdnlPNGMxQWdNQkFBR2pnWU13Z1lBd0hRWURWUjBPQkJZRUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1IKTUI4R0ExVWRJd1FZTUJhQUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1JNQjBHQTFVZEpRUVdNQlFHQ0NzRwpBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BNEdBMVVkRHdFQi93UUVBd0lCCkJqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFYbEh4M2tIMDZwY2NDTDlEVE5qTnBCYnlVSytGd2R6T2IwWFYKcmpNaGtxdHVmdEpUUnR5T3hKZ0ZKNXhUR3pCdEtKamcrVU1pczBOV0t0VDBNWThVMU45U2c5SDl0RFpHRHBjVQpxMlVRU0Y4dXRQMVR3dnJIUzIrdzB2MUoxdHgrTEFiU0lmWmJCV0xXQ21EODUzRlVoWlFZekkvYXpFM28vd0p1CmlPUklMdUpNUk5vNlBXY3VLZmRFVkhaS1RTWnk3a25FcHNidGtsN3EwRE91eUFWdG9HVnlkb3VUR0FOdFhXK2YKczNUSTJjKzErZXg3L2RZOEJGQTFzNWFUOG5vZnU3T1RTTzdiS1kzSkRBUHZOeFQzKzVZUXJwNGR1Nmh0YUFMbAppOHNaRkhidmxpd2EzdlhxL3p1Y2JEaHEzQzBhZnAzV2ZwRGxwSlpvLy9QUUFKaTZLQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + + tests := []struct { + name string + spec func() *v1alpha1.WebhookAuthenticatorSpec + initialPhase v1alpha1.WebhookAuthenticatorPhase + finalConditions []metav1.Condition + run func(t *testing.T) + }{ + { + name: "Basic test to see if the WebhookAuthenticator wakes up or not.", + spec: func() *v1alpha1.WebhookAuthenticatorSpec { + return &testlib.IntegrationEnv(t).TestWebhook + }, + initialPhase: v1alpha1.WebhookAuthenticatorPhaseReady, + finalConditions: allSuccessfulWebhookAuthenticatorConditions(), + }, + { + name: "valid spec with invalid CA in TLS config will result in a WebhookAuthenticator that is not ready", + spec: func() *v1alpha1.WebhookAuthenticatorSpec { + caBundleString := "invalid base64-encoded data" + webhookSpec := testEnv.TestWebhook.DeepCopy() + webhookSpec.TLS = &v1alpha1.TLSSpec{ + CertificateAuthorityData: caBundleString, + } + return webhookSpec + }, + initialPhase: v1alpha1.WebhookAuthenticatorPhaseError, + finalConditions: replaceSomeConditions( + allSuccessfulWebhookAuthenticatorConditions(), + []metav1.Condition{ + { + Type: "Ready", + Status: "False", + Reason: "NotReady", + Message: "the WebhookAuthenticator is not ready: see other conditions for details", + }, { + Type: "AuthenticatorValid", + Status: "Unknown", + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + }, { + Type: "TLSConfigurationValid", + Status: "False", + Reason: "InvalidTLSConfiguration", + Message: "invalid TLS configuration: illegal base64 data at input byte 7", + }, { + Type: "WebhookConnectionValid", + Status: "Unknown", + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + }, + }, + ), + }, + { + name: "valid spec with valid CA in TLS config but does not match issuer server will result in a WebhookAuthenticator that is not ready", + spec: func() *v1alpha1.WebhookAuthenticatorSpec { + webhookSpec := testEnv.TestWebhook.DeepCopy() + webhookSpec.TLS = &v1alpha1.TLSSpec{ + CertificateAuthorityData: caBundleSomePivotalCA, + } + return webhookSpec + }, + initialPhase: v1alpha1.WebhookAuthenticatorPhaseError, + finalConditions: replaceSomeConditions( + allSuccessfulWebhookAuthenticatorConditions(), + []metav1.Condition{ + { + Type: "Ready", + Status: "False", + Reason: "NotReady", + Message: "the WebhookAuthenticator is not ready: see other conditions for details", + }, { + Type: "AuthenticatorValid", + Status: "Unknown", + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + }, { + Type: "WebhookConnectionValid", + Status: "False", + Reason: "UnableToDialServer", + Message: "cannot dial server: tls: failed to verify certificate: x509: certificate signed by unknown authority", + }, + }, + ), + }, + { + name: "invalid with unresponsive endpoint will result in a WebhookAuthenticator that is not ready", + spec: func() *v1alpha1.WebhookAuthenticatorSpec { + webhookSpec := testEnv.TestWebhook.DeepCopy() + webhookSpec.TLS = &v1alpha1.TLSSpec{ + CertificateAuthorityData: caBundleSomePivotalCA, + } + webhookSpec.Endpoint = "https://127.0.0.1:443/some-fake-endpoint" + return webhookSpec + }, + initialPhase: v1alpha1.WebhookAuthenticatorPhaseError, + finalConditions: replaceSomeConditions( + allSuccessfulWebhookAuthenticatorConditions(), + []metav1.Condition{ + { + Type: "Ready", + Status: "False", + Reason: "NotReady", + Message: "the WebhookAuthenticator is not ready: see other conditions for details", + }, { + Type: "AuthenticatorValid", + Status: "Unknown", + Reason: "UnableToValidate", + Message: "unable to validate; see other conditions for details", + }, { + Type: "WebhookConnectionValid", + Status: "False", + Reason: "UnableToDialServer", + Message: "cannot dial server: dial tcp 127.0.0.1:443: connect: connection refused", + }, + }, + ), + }, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + webhookAuthenticator := testlib.CreateTestWebhookAuthenticator( + ctx, + t, + tt.spec(), + tt.initialPhase) + + testlib.WaitForWebhookAuthenticatorStatusConditions( + ctx, t, + webhookAuthenticator.Name, + tt.finalConditions) + }) + } +} + +func TestConciergeWebhookAuthenticatorCRDValidations_Parallel(t *testing.T) { + env := testlib.IntegrationEnv(t) + webhookAuthenticatorClient := testlib.NewConciergeClientset(t).AuthenticationV1alpha1().WebhookAuthenticators() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + t.Cleanup(cancel) + + objectMeta := testlib.ObjectMetaWithRandomName(t, "webhook-authenticator") + tests := []struct { + name string + webhookAuthenticator *v1alpha1.WebhookAuthenticator + wantErr string + }{ + { + name: "endpoint can not be empty string", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: objectMeta, + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "", + }, + }, + wantErr: `WebhookAuthenticator.authentication.concierge.` + env.APIGroupSuffix + ` "` + objectMeta.Name + `" is invalid: ` + + `spec.endpoint: Invalid value: "": spec.endpoint in body should be at least 1 chars long`, + }, + { + name: "endpoint must be https", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: objectMeta, + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "http://www.example.com", + }, + }, + wantErr: `WebhookAuthenticator.authentication.concierge.` + env.APIGroupSuffix + ` "` + objectMeta.Name + `" is invalid: ` + + `spec.endpoint: Invalid value: "http://www.example.com": spec.endpoint in body should match '^https://'`, + }, + { + name: "minimum valid authenticator", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "webhook"), + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://localhost/webhook-isnt-actually-here", + }, + }, + }, + { + name: "valid authenticator can have empty TLS block", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "webhook"), + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://localhost/webhook-isnt-actually-here", + TLS: &v1alpha1.TLSSpec{}, + }, + }, + }, + { + name: "valid authenticator can have empty TLS CertificateAuthorityData", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "jwtauthenticator"), + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://localhost/webhook-isnt-actually-here", + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: "", + }, + }, + }, + }, + { + // since the CRD validations do not assess fitness of the value provided + name: "valid authenticator can have TLS CertificateAuthorityData string that is an invalid certificate", + webhookAuthenticator: &v1alpha1.WebhookAuthenticator{ + ObjectMeta: testlib.ObjectMetaWithRandomName(t, "jwtauthenticator"), + Spec: v1alpha1.WebhookAuthenticatorSpec{ + Endpoint: "https://localhost/webhook-isnt-actually-here", + TLS: &v1alpha1.TLSSpec{ + CertificateAuthorityData: "pretend-this-is-a-certificate", + }, + }, + }, + }, + } + for _, test := range tests { + tt := test + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, createErr := webhookAuthenticatorClient.Create(ctx, tt.webhookAuthenticator, metav1.CreateOptions{}) + + t.Cleanup(func() { + // delete if it exists + delErr := webhookAuthenticatorClient.Delete(ctx, tt.webhookAuthenticator.Name, metav1.DeleteOptions{}) + if !errors.IsNotFound(delErr) { + require.NoError(t, delErr) + } + }) + + if tt.wantErr != "" { + wantErr := tt.wantErr + require.EqualError(t, createErr, wantErr) + } else { + require.NoError(t, createErr) + } + }) + } +} + +func allSuccessfulWebhookAuthenticatorConditions() []metav1.Condition { + return []metav1.Condition{{ + Type: "AuthenticatorValid", + Status: "True", + Reason: "Success", + Message: "authenticator initialized", + }, + { + Type: "EndpointURLValid", + Status: "True", + Reason: "Success", + Message: "endpoint is a valid URL", + }, + { + Type: "Ready", + Status: "True", + Reason: "Success", + Message: "the WebhookAuthenticator is ready", + }, + { + Type: "TLSConfigurationValid", + Status: "True", + Reason: "Success", + Message: "successfully parsed specified CA bundle", + }, + { + Type: "WebhookConnectionValid", + Status: "True", + Reason: "Success", + Message: "tls verified", + }, + } +} diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index f7756f393..327c31a45 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -438,7 +438,7 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr } // manually update this value whenever you add additional fields to an API resource and then run the generator - totalExpectedAPIFields := 260 + totalExpectedAPIFields := 261 // Because we are parsing text from `kubectl explain` and because the format of that text can change // over time, make a rudimentary assertion that this test exercised the whole tree of all fields of all diff --git a/test/testlib/client.go b/test/testlib/client.go index 4af9c52c9..9be61147a 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -171,9 +171,12 @@ func NewKubeclient(t *testing.T, config *rest.Config) *kubeclient.Client { // CreateTestWebhookAuthenticator creates and returns a test WebhookAuthenticator in $PINNIPED_TEST_CONCIERGE_NAMESPACE, which will be // automatically deleted at the end of the current test's lifetime. It returns a corev1.TypedLocalObjectReference which // describes the test webhook authenticator within the test namespace. -func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { +func CreateTestWebhookAuthenticator( + ctx context.Context, + t *testing.T, + webhookSpec *auth1alpha1.WebhookAuthenticatorSpec, + expectedStatus auth1alpha1.WebhookAuthenticatorPhase) corev1.TypedLocalObjectReference { t.Helper() - testEnv := IntegrationEnv(t) client := NewConciergeClientset(t) webhooks := client.AuthenticationV1alpha1().WebhookAuthenticators() @@ -183,7 +186,7 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty webhook, err := webhooks.Create(createContext, &auth1alpha1.WebhookAuthenticator{ ObjectMeta: testObjectMeta(t, "webhook"), - Spec: testEnv.TestWebhook, + Spec: *webhookSpec, }, metav1.CreateOptions{}) require.NoError(t, err, "could not create test WebhookAuthenticator") t.Logf("created test WebhookAuthenticator %s", webhook.Name) @@ -197,6 +200,10 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty require.NoErrorf(t, err, "could not cleanup test WebhookAuthenticator %s/%s", webhook.Namespace, webhook.Name) }) + if expectedStatus != "" { + WaitForWebhookAuthenticatorStatusPhase(ctx, t, webhook.Name, expectedStatus) + } + return corev1.TypedLocalObjectReference{ APIGroup: &auth1alpha1.SchemeGroupVersion.Group, Kind: "WebhookAuthenticator", @@ -204,6 +211,47 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty } } +func WaitForWebhookAuthenticatorStatusPhase( + ctx context.Context, + t *testing.T, + webhookName string, + expectPhase auth1alpha1.WebhookAuthenticatorPhase) { + t.Helper() + webhookAuthenticatorClientSet := NewConciergeClientset(t).AuthenticationV1alpha1().WebhookAuthenticators() + + RequireEventuallyf(t, func(requireEventually *require.Assertions) { + webhookA, err := webhookAuthenticatorClientSet.Get(ctx, webhookName, metav1.GetOptions{}) + requireEventually.NoError(err) + requireEventually.Equalf(expectPhase, webhookA.Status.Phase, "actual status conditions were: %#v", webhookA.Status.Conditions) + }, 60*time.Second, 1*time.Second, "expected the WebhookAuthenticator to have status %q", expectPhase) +} + +func WaitForWebhookAuthenticatorStatusConditions(ctx context.Context, t *testing.T, webhookName string, expectConditions []metav1.Condition) { + t.Helper() + webhookClient := NewConciergeClientset(t).AuthenticationV1alpha1().WebhookAuthenticators() + RequireEventuallyf(t, func(requireEventually *require.Assertions) { + fd, err := webhookClient.Get(ctx, webhookName, 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 WebhookAuthenticator conditions") +} + // CreateTestJWTAuthenticatorForCLIUpstream creates and returns a test JWTAuthenticator which will be automatically // deleted at the end of the current test's lifetime. //