ContainerImage.Pinniped/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go

318 lines
12 KiB
Go

// Copyright 2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package oidcclientwatcher
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/equality"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
corev1informers "k8s.io/client-go/informers/core/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1"
pinnipedclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned"
configInformers "go.pinniped.dev/generated/latest/client/supervisor/informers/externalversions/config/v1alpha1"
pinnipedcontroller "go.pinniped.dev/internal/controller"
"go.pinniped.dev/internal/controller/conditionsutil"
"go.pinniped.dev/internal/controllerlib"
"go.pinniped.dev/internal/oidcclientsecretstorage"
"go.pinniped.dev/internal/plog"
)
const (
clientSecretExists = "ClientSecretExists"
allowedGrantTypesValid = "AllowedGrantTypesValid"
allowedScopesValid = "AllowedScopesValid"
reasonSuccess = "Success"
reasonMissingRequiredValue = "MissingRequiredValue"
reasonNoClientSecretFound = "NoClientSecretFound"
authorizationCodeGrantTypeName = "authorization_code"
refreshTokenGrantTypeName = "refresh_token"
tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential
openidScopeName = "openid"
offlineAccessScopeName = "offline_access"
requestAudienceScopeName = "pinniped:request-audience"
usernameScopeName = "username"
groupsScopeName = "groups"
allowedGrantTypesFieldName = "allowedGrantTypes"
allowedScopesFieldName = "allowedScopes"
secretTypeToObserve = "storage.pinniped.dev/oidc-client-secret" //nolint:gosec // this is not a credential
)
type oidcClientWatcherController struct {
pinnipedClient pinnipedclientset.Interface
oidcClientInformer configInformers.OIDCClientInformer
secretInformer corev1informers.SecretInformer
}
// NewOIDCClientWatcherController returns a controllerlib.Controller that watches OIDCClients and updates
// their status with validation errors.
func NewOIDCClientWatcherController(
pinnipedClient pinnipedclientset.Interface,
secretInformer corev1informers.SecretInformer,
oidcClientInformer configInformers.OIDCClientInformer,
withInformer pinnipedcontroller.WithInformerOptionFunc,
) controllerlib.Controller {
return controllerlib.New(
controllerlib.Config{
Name: "OIDCClientWatcherController",
Syncer: &oidcClientWatcherController{
pinnipedClient: pinnipedClient,
secretInformer: secretInformer,
oidcClientInformer: oidcClientInformer,
},
},
// We want to be notified when an OIDCClient's corresponding secret gets updated or deleted.
withInformer(
secretInformer,
pinnipedcontroller.MatchAnySecretOfTypeFilter(secretTypeToObserve, pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
// We want to be notified when anything happens to an OIDCClient.
withInformer(
oidcClientInformer,
pinnipedcontroller.MatchAnythingFilter(pinnipedcontroller.SingletonQueue()),
controllerlib.InformerOption{},
),
)
}
// Sync implements controllerlib.Syncer.
func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error {
// Sync could be called on either a Secret or an OIDCClient, so to keep it simple, revalidate
// all OIDCClients whenever anything changes.
oidcClients, err := c.oidcClientInformer.Lister().List(labels.Everything())
if err != nil {
return fmt.Errorf("failed to list OIDCClients: %w", err)
}
// We're only going to use storage to call GetName(), which happens to not need the constructor params.
// This is because we can read the Secrets from the informer cache here, instead of doing live reads.
storage := oidcclientsecretstorage.New(nil, nil)
for _, oidcClient := range oidcClients {
correspondingSecretName := storage.GetName(oidcClient.UID)
secret, err := c.secretInformer.Lister().Secrets(oidcClient.Namespace).Get(correspondingSecretName)
if err != nil {
if !k8serrors.IsNotFound(err) {
// Anything other than a NotFound error is unexpected when reading from an informer.
return fmt.Errorf("failed to get %s/%s secret: %w", oidcClient.Namespace, correspondingSecretName, err)
}
// Got a NotFound error, so continue. The Secret just doesn't exist yet, which is okay.
plog.DebugErr(
"OIDCClientWatcherController error getting storage Secret for OIDCClient's client secrets", err,
"oidcClientName", oidcClient.Name,
"oidcClientNamespace", oidcClient.Namespace,
"secretName", correspondingSecretName,
)
secret = nil
}
conditions := validateOIDCClient(oidcClient, secret)
if err := c.updateStatus(ctx.Context, oidcClient, conditions); err != nil {
return fmt.Errorf("cannot update OIDCClient '%s/%s': %w", oidcClient.Namespace, oidcClient.Name, err)
}
plog.Debug(
"OIDCClientWatcherController Sync updated an OIDCClient",
"oidcClientName", oidcClient.Name,
"oidcClientNamespace", oidcClient.Namespace,
"conditionsCount", len(conditions),
)
}
return nil
}
// validateOIDCClient validates the OIDCClient and its corresponding client secret storage Secret.
// When the corresponding client secret storage Secret was not found, pass nil to this function to
// get the validation error for that case.
func validateOIDCClient(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret) []*v1alpha1.Condition {
c := validateSecret(secret, []*v1alpha1.Condition{})
c = validateAllowedGrantTypes(oidcClient, c)
c = validateAllowedScopes(oidcClient, c)
return c
}
// validateAllowedScopes checks if allowedScopes is valid on the OIDCClient.
func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
switch {
case !allowedScopesContains(oidcClient, openidScopeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName),
})
case allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must be included in %q when %q is included in %q",
offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName),
})
case allowedScopesContains(oidcClient, requestAudienceScopeName) &&
(!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q and %q must be included in %q when %q is included in %q",
usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName),
})
case allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must be included in %q when %q is included in %q",
requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName),
})
default:
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedScopesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedScopesFieldName),
})
}
return conditions
}
// validateAllowedGrantTypes checks if allowedGrantTypes is valid on the OIDCClient.
func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
switch {
case !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must always be included in %q",
authorizationCodeGrantTypeName, allowedGrantTypesFieldName),
})
case allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must be included in %q when %q is included in %q",
refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName),
})
case allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName):
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionFalse,
Reason: reasonMissingRequiredValue,
Message: fmt.Sprintf("%q must be included in %q when %q is included in %q",
tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName),
})
default:
conditions = append(conditions, &v1alpha1.Condition{
Type: allowedGrantTypesValid,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%q is valid", allowedGrantTypesFieldName),
})
}
return conditions
}
// validateSecret checks if the client secret storage Secret is valid and contains at least one client secret.
func validateSecret(secret *v1.Secret, conditions []*v1alpha1.Condition) []*v1alpha1.Condition {
if secret == nil {
// Invalid: no storage Secret found.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (no Secret storage found)",
})
return conditions
}
storedClientSecret, err := oidcclientsecretstorage.ReadFromSecret(secret)
if err != nil {
// Invalid: storage Secret exists but its data could not be parsed.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: fmt.Sprintf("error reading client secret storage: %s", err.Error()),
})
return conditions
}
// Successfully read the stored client secrets, so check if there are any stored in the list.
storedClientSecretsCount := len(storedClientSecret.SecretHashes)
if storedClientSecretsCount == 0 {
// Invalid: no client secrets stored.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionFalse,
Reason: reasonNoClientSecretFound,
Message: "no client secret found (empty list in storage)",
})
} else {
// Valid: has at least one client secret stored for this OIDC client.
conditions = append(conditions, &v1alpha1.Condition{
Type: clientSecretExists,
Status: v1alpha1.ConditionTrue,
Reason: reasonSuccess,
Message: fmt.Sprintf("%d client secret(s) found", storedClientSecretsCount),
})
}
return conditions
}
func allowedGrantTypesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedGrantTypes {
if hay == v1alpha1.GrantType(needle) {
return true
}
}
return false
}
func allowedScopesContains(haystack *v1alpha1.OIDCClient, needle string) bool {
for _, hay := range haystack.Spec.AllowedScopes {
if hay == v1alpha1.Scope(needle) {
return true
}
}
return false
}
func (c *oidcClientWatcherController) updateStatus(ctx context.Context, upstream *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) error {
updated := upstream.DeepCopy()
hadErrorCondition := conditionsutil.MergeConfigConditions(conditions, upstream.Generation, &updated.Status.Conditions, plog.New())
updated.Status.Phase = v1alpha1.PhaseReady
if hadErrorCondition {
updated.Status.Phase = v1alpha1.PhaseError
}
if equality.Semantic.DeepEqual(upstream, updated) {
return nil
}
_, err := c.pinnipedClient.
ConfigV1alpha1().
OIDCClients(upstream.Namespace).
UpdateStatus(ctx, updated, metav1.UpdateOptions{})
return err
}