diff --git a/controllers/capabilities/applicationauth_controller.go b/controllers/capabilities/applicationauth_controller.go index 451122089..ea1242020 100644 --- a/controllers/capabilities/applicationauth_controller.go +++ b/controllers/capabilities/applicationauth_controller.go @@ -18,15 +18,13 @@ package controllers import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "strconv" - "time" + "strings" capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" controllerhelper "github.com/3scale/3scale-operator/pkg/controller/helper" + rand "github.com/3scale/3scale-operator/pkg/crypto/rand" "github.com/3scale/3scale-operator/pkg/helper" "github.com/3scale/3scale-operator/pkg/reconcilers" "github.com/3scale/3scale-operator/version" @@ -35,6 +33,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -48,20 +47,20 @@ type AuthSecret struct { UserKey string ApplicationKey string ApplicationID string + ClientSecret string } const ( UserKey = "UserKey" ApplicationKey = "ApplicationKey" ApplicationID = "ApplicationID" + ClientSecret = "ClientSecret" ) // +kubebuilder:rbac:groups=capabilities.3scale.net,resources=applicationauths,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=capabilities.3scale.net,resources=applicationauths/status,verbs=get;update;patch func (r *ApplicationAuthReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = context.Background() - // _ = r.Log.WithValues("applicationauth", req.NamespacedName) reqLogger := r.Logger().WithValues("applicationauth", req.NamespacedName) reqLogger.Info("Reconcile Application Authentication", "Operator version", version.Version) @@ -84,139 +83,108 @@ func (r *ApplicationAuthReconciler) Reconcile(ctx context.Context, req ctrl.Requ } reqLogger.V(1).Info(string(jsonData)) } - // get the application - application := &capabilitiesv1beta1.Application{} - // Retrieve application CR, on failed retrieval update status and requeue - err = r.Client().Get(r.Context(), types.NamespacedName{Name: applicationAuth.Spec.ApplicationCRName, Namespace: applicationAuth.Namespace}, application) - if err != nil { - // If the product CR is not found, update status and requeue - if errors.IsNotFound(err) { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - reqLogger.Info("Application CR not found. Ignoring since object must have been deleted") - statusResult, statusErr := statusReconciler.Reconcile() - // Reconcile status first as the reconcilerError might need to be updated to the status section of the CR before requeueing - if statusErr != nil { - return ctrl.Result{}, statusErr - } - if statusResult.Requeue { - reqLogger.Info("Reconciling status not finished. Requeueing.") - return statusResult, nil + if !applicationAuth.Status.Conditions.IsTrueFor(capabilitiesv1beta1.ApplicationAuthReadyConditionType) { + // Retrieve application CR, on failed retrieval update status and requeue + application := &capabilitiesv1beta1.Application{} + err = r.Client().Get(r.Context(), types.NamespacedName{Name: applicationAuth.Spec.ApplicationCRName, Namespace: applicationAuth.Namespace}, application) + if err != nil { + // If the product CR is not found, update status and requeue + if errors.IsNotFound(err) { + reqLogger.Info("Application CR not found. Ignoring since object must have been deleted") + return r.reconcileStatus(applicationAuth, err, reqLogger) } - } - // If API call error, return err - return ctrl.Result{}, err - } + // If API call error, return err + return ctrl.Result{}, err + } - // get the product - developerAccount := &capabilitiesv1beta1.DeveloperAccount{} + // Make sure application is ready + err = checkApplicationResources(applicationAuth, application) + if err != nil { + return r.reconcileStatus(applicationAuth, err, reqLogger) + } - // Retrieve product CR, on failed retrieval update status and requeue - err = r.Client().Get(r.Context(), types.NamespacedName{Name: application.Spec.AccountCR.Name, Namespace: applicationAuth.Namespace}, developerAccount) - if err != nil { - // If the product CR is not found, update status and requeue - if errors.IsNotFound(err) { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - reqLogger.Info("DeveloperAccount CR not found. Ignoring since object must have been deleted") - statusResult, statusErr := statusReconciler.Reconcile() - // Reconcile status first as the reconcilerError might need to be updated to the status section of the CR before requeueing - if statusErr != nil { - return ctrl.Result{}, statusErr - } - if statusResult.Requeue { - reqLogger.Info("Reconciling status not finished. Requeueing.") - return statusResult, nil + // Retrieve DeveloperAccount CR, on failed retrieval update status and requeue + developerAccount := &capabilitiesv1beta1.DeveloperAccount{} + err = r.Client().Get(r.Context(), types.NamespacedName{Name: application.Spec.AccountCR.Name, Namespace: applicationAuth.Namespace}, developerAccount) + if err != nil { + // If the product CR is not found, update status and requeue + if errors.IsNotFound(err) { + reqLogger.Info("DeveloperAccount CR not found. Ignoring since object must have been deleted") + return r.reconcileStatus(applicationAuth, err, reqLogger) } - } - // If API call error, return err - return ctrl.Result{}, err - } - // get the application - product := &capabilitiesv1beta1.Product{} + // If API call error, return err + return ctrl.Result{}, err + } - // Retrieve application CR, on failed retrieval update status and requeue - err = r.Client().Get(r.Context(), types.NamespacedName{Name: application.Spec.ProductCR.Name, Namespace: applicationAuth.Namespace}, product) - if err != nil { - // If the product CR is not found, update status and requeue - if errors.IsNotFound(err) { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - reqLogger.Info("Application CR not found. Ignoring since object must have been deleted") - statusResult, statusErr := statusReconciler.Reconcile() - // Reconcile status first as the reconcilerError might need to be updated to the status section of the CR before requeueing - if statusErr != nil { - return ctrl.Result{}, statusErr - } - if statusResult.Requeue { - reqLogger.Info("Reconciling status not finished. Requeueing.") - return statusResult, nil + // Retrieve Product CR, on failed retrieval update status and requeue + product := &capabilitiesv1beta1.Product{} + err = r.Client().Get(r.Context(), types.NamespacedName{Name: application.Spec.ProductCR.Name, Namespace: applicationAuth.Namespace}, product) + if err != nil { + // If the product CR is not found, update status and requeue + if errors.IsNotFound(err) { + reqLogger.Info("Product CR not found. Ignoring since object must have been deleted") + return r.reconcileStatus(applicationAuth, err, reqLogger) } - } - // If API call error, return err - return ctrl.Result{}, err - } + // If API call error, return err + return ctrl.Result{}, err + } - // Retrieve providerAccountRef - providerAccount, err := controllerhelper.LookupProviderAccount(r.Client(), applicationAuth.GetNamespace(), applicationAuth.Spec.ProviderAccountRef, r.Logger()) - if err != nil { - return ctrl.Result{}, err - } + authMode := product.Spec.AuthenticationMode() + if authMode == nil { + err := fmt.Errorf("unable to identify authentication mode from Product CR") + return r.reconcileStatus(applicationAuth, err, reqLogger) + } - // connect to the 3scale porta client - insecureSkipVerify := controllerhelper.GetInsecureSkipVerifyAnnotation(applicationAuth.GetAnnotations()) - threescaleAPIClient, err := controllerhelper.PortaClient(providerAccount, insecureSkipVerify) - if err != nil { - return ctrl.Result{}, err - } + // Retrieve providerAccountRef + providerAccount, err := controllerhelper.LookupProviderAccount(r.Client(), applicationAuth.GetNamespace(), applicationAuth.Spec.ProviderAccountRef, r.Logger()) + if err != nil { + return ctrl.Result{}, err + } - // get the authSecret - authSecretObj := &corev1.Secret{} + // connect to the 3scale porta client + insecureSkipVerify := controllerhelper.GetInsecureSkipVerifyAnnotation(applicationAuth.GetAnnotations()) + threescaleAPIClient, err := controllerhelper.PortaClient(providerAccount, insecureSkipVerify) + if err != nil { + return ctrl.Result{}, err + } - // Retrieve auth secret, on failed retrieval update status and requeue - err = r.Client().Get(r.Context(), types.NamespacedName{Name: applicationAuth.Spec.AuthSecretRef.Name, Namespace: applicationAuth.Namespace}, authSecretObj) - if err != nil { - // If the product CR is not found, update status and requeue - if errors.IsNotFound(err) { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - reqLogger.Info("ApplicationAuth secret not found. Ignoring since object must have been deleted") - statusResult, statusErr := statusReconciler.Reconcile() - // Reconcile status first as the reconcilerError might need to be updated to the status section of the CR before requeueing - if statusErr != nil { - return ctrl.Result{}, statusErr - } - if statusResult.Requeue { - reqLogger.Info("Reconciling status not finished. Requeueing.") - return statusResult, nil + // Retrieve auth secret, on failed retrieval update status and requeue + authSecretObj := &corev1.Secret{} + err = r.Client().Get(r.Context(), types.NamespacedName{Name: applicationAuth.Spec.AuthSecretRef.Name, Namespace: applicationAuth.Namespace}, authSecretObj) + if err != nil { + // If the product CR is not found, update status and requeue + if errors.IsNotFound(err) { + reqLogger.Info("ApplicationAuth secret not found. Ignoring since object must have been deleted") + return r.reconcileStatus(applicationAuth, err, reqLogger) } + return ctrl.Result{}, err } - return ctrl.Result{}, err - } - // populate authSecret struct - authSecret := authSecretReferenceSource(r.Client(), applicationAuth.Namespace, applicationAuth.Spec.AuthSecretRef, reqLogger) - if !applicationAuth.Status.Conditions.IsTrueFor(capabilitiesv1beta1.ApplicationAuthReadyConditionType) { - statusReconciler, reconcileErr := r.applicationAuthReconciler(applicationAuth, developerAccount, application, product, *authSecret, threescaleAPIClient) - if statusReconciler != nil { - statusResult, statusErr := statusReconciler.Reconcile() + controller, err := GetAuthController(*authMode, reqLogger) + if err != nil { + return ctrl.Result{}, err + } - if statusErr != nil { - return ctrl.Result{}, statusErr - } - if statusResult.Requeue { - reqLogger.Info("Reconciling status not finished. Requeueing.") - return statusResult, nil - } - // If reconcile error but no status update required, requeue. - if reconcileErr != nil { - return helper.ReconcileErrorHandler(reconcileErr, reqLogger), nil - } + // populate authSecret struct and make sure required fields are available + shouldGenerateSecret := applicationAuth.Spec.GenerateSecret != nil && *applicationAuth.Spec.GenerateSecret + reqLogger.Info("LookupAuthSecret", "ns", applicationAuth.Namespace, "authSecretRef", applicationAuth.Spec.AuthSecretRef) + authSecret, err := controller.SecretReferenceSource(r.Client(), applicationAuth.Namespace, applicationAuth.Spec.AuthSecretRef, shouldGenerateSecret) + if err != nil { + return r.reconcileStatus(applicationAuth, err, reqLogger) + } + + err = controller.Sync(threescaleAPIClient, *developerAccount.Status.ID, *application.Status.ID, *authSecret) + if err != nil { + return r.reconcileStatus(applicationAuth, err, reqLogger) } } // final return reqLogger.Info("Successfully reconciled") - return ctrl.Result{}, nil + return r.reconcileStatus(applicationAuth, nil, reqLogger) } func (r *ApplicationAuthReconciler) SetupWithManager(mgr ctrl.Manager) error { @@ -225,124 +193,258 @@ func (r *ApplicationAuthReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *ApplicationAuthReconciler) applicationAuthReconciler( - applicationAuth *capabilitiesv1beta1.ApplicationAuth, - developerAccount *capabilitiesv1beta1.DeveloperAccount, - application *capabilitiesv1beta1.Application, - product *capabilitiesv1beta1.Product, - authSecret AuthSecret, - threescaleClient *threescaleapi.ThreeScaleClient, -) (*ApplicationAuthStatusReconciler, error) { - // generate sha base of timestamp - timestamp := time.Now().Unix() - // Write the timestamp string and encode to hash - hash := sha256.New() - hash.Write([]byte(strconv.FormatInt(timestamp, 10))) - hashedBytes := hash.Sum(nil) - hashedString := hex.EncodeToString(hashedBytes) - - // Check the values if populated or the GenerateSecret field is true and make the api call to update - // If UserKey is not populated generate random sha - if authSecret.UserKey == "" && *applicationAuth.Spec.GenerateSecret { - authSecret.UserKey = hashedString +type AuthController interface { + Sync(threescaleClient *threescaleapi.ThreeScaleClient, developerAccountID int64, applicationID int64, authSecret AuthSecret) error + SecretReferenceSource(cl client.Client, ns string, authSectretRef *corev1.LocalObjectReference, generateSecret bool) (*AuthSecret, error) +} + +func GetAuthController(mode string, logger logr.Logger) (AuthController, error) { + switch mode { + case "1": + return &userKeyAuthMode{logger: logger}, nil + case "2": + return &appIDAuthMode{logger: logger}, nil + case "oidc": + return &oidcAuthMode{logger: logger}, nil + default: + return nil, fmt.Errorf("unknown authentication mode") } - if authSecret.UserKey != "" { +} + +type userKeyAuthMode struct { + logger logr.Logger +} + +func (u *userKeyAuthMode) Sync(threescaleClient *threescaleapi.ThreeScaleClient, developerAccountID int64, applicationID int64, authSecret AuthSecret) error { + // get the existing value from the porta + existingApplication, err := threescaleClient.Application(developerAccountID, applicationID) + if err != nil { + return err + } + existingKey := existingApplication.UserKey + + // user_key mismatch, update + if existingKey != authSecret.UserKey { params := make(map[string]string) - // this key "user_key" is configurable so will need to get the product to see if its the default or not - if product.Spec.AuthUserKey() == nil { - params["user_key"] = authSecret.UserKey - } else { - params[*product.Spec.AuthUserKey()] = authSecret.UserKey + params["user_key"] = authSecret.UserKey + if _, err := threescaleClient.UpdateApplication(developerAccountID, applicationID, params); err != nil { + return err } - // edge case if the operator is stopped before reconcile finished need to nil check application.Status.ID - if application.Status.ID != nil { - _, err := threescaleClient.UpdateApplication(*developerAccount.Status.ID, *application.Status.ID, params) - if err != nil { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err + } + return nil +} + +func (u *userKeyAuthMode) SecretReferenceSource(cl client.Client, ns string, authSectretRef *corev1.LocalObjectReference, generateSecret bool) (*AuthSecret, error) { + secretSource := helper.NewSecretSource(cl, ns) + userKeyStr, err := secretSource.RequiredFieldValueFromRequiredSecret(authSectretRef.Name, UserKey) + if err != nil { + return nil, err + } + + if userKeyStr == "" { + if generateSecret { + userKeyStr = rand.String(16) + + newValues := map[string][]byte{ + UserKey: []byte(userKeyStr), } + + if err := updateSecret(context.Background(), cl, authSectretRef.Name, ns, newValues); err != nil { + return nil, err + } + } else { + // Nothing available raise error now + return nil, fmt.Errorf("no UserKey available in secret and generate secret is set to false") } } + return &AuthSecret{UserKey: userKeyStr}, nil +} - if authSecret.ApplicationKey != "" { - // edge case if the operator is stopped before reconcile finished need to nil check application.Status.ID - if application.Status.ID != nil { - foundApplication, err := threescaleClient.CreateApplicationKey(*developerAccount.Status.ID, *application.Status.ID, authSecret.ApplicationKey) - if err != nil { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err - } +type appIDAuthMode struct { + logger logr.Logger +} + +func (a *appIDAuthMode) Sync(threescaleClient *threescaleapi.ThreeScaleClient, developerAccountID int64, applicationID int64, authSecret AuthSecret) error { + desiredKeys := strings.Split(authSecret.ApplicationKey, ",") + if len(desiredKeys) > 5 { + return fmt.Errorf("secret contains more than 5 application_key") + } - authSecret.ApplicationID = foundApplication.ApplicationId + // get the existing value from the portal + applicationKeys, err := threescaleClient.ApplicationKeys(developerAccountID, applicationID) + if err != nil { + return err + } + + existingKeys := make([]string, 0, len(applicationKeys)) + for _, key := range applicationKeys { + existingKeys = append(existingKeys, key.Value) + } + + // delete existing and not desired + notDesiredExistingKeys := helper.ArrayStringDifference(existingKeys, desiredKeys) + a.logger.V(1).Info("syncApplicationAuth", "notDesiredExistingKeys", notDesiredExistingKeys) + for _, key := range notDesiredExistingKeys { + // key is expected to exist + // notDesiredExistingKeys is a subset of the existingMap key set + if err := threescaleClient.DeleteApplicationKey(developerAccountID, applicationID, key); err != nil { + return fmt.Errorf("error sync applicationAuth for developerAccountID: %d, applicationID: %d, error: %w", developerAccountID, applicationID, err) } } - if applicationAuth.Spec.GenerateSecret != nil && *applicationAuth.Spec.GenerateSecret { - if application.Status.ID != nil { - foundApplication, err := threescaleClient.CreateApplicationRandomKey(*developerAccount.Status.ID, *application.Status.ID) - if err != nil { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err + // Create not existing and desired + desiredNewKeys := helper.ArrayStringDifference(desiredKeys, existingKeys) + a.logger.V(1).Info("syncApplicationPlans", "desiredNewKeys", desiredNewKeys) + for _, key := range desiredNewKeys { + // key is expected to exist + // desiredNewKeys is a subset of the Spec.ApplicationPlans map key set + if _, err := threescaleClient.CreateApplicationKey(developerAccountID, applicationID, key); err != nil { + return fmt.Errorf("error sync applicationAuth for developerAccountID: %d, applicationID: %d, error: %w", developerAccountID, applicationID, err) + } + } + + return nil +} + +func (a *appIDAuthMode) SecretReferenceSource(cl client.Client, ns string, authSectretRef *corev1.LocalObjectReference, generateSecret bool) (*AuthSecret, error) { + secretSource := helper.NewSecretSource(cl, ns) + applicationKeyStr, err := secretSource.RequiredFieldValueFromRequiredSecret(authSectretRef.Name, ApplicationKey) + if err != nil { + return nil, err + } + + if applicationKeyStr == "" { + if generateSecret { + applicationKeyStr = rand.String(16) + + newValues := map[string][]byte{ + ApplicationKey: []byte(applicationKeyStr), } - authSecret.ApplicationID = foundApplication.ApplicationId - var foundApplicationKeys []threescaleapi.ApplicationKey - foundApplicationKeys, err = threescaleClient.ApplicationKeys(*developerAccount.Status.ID, *application.Status.ID) - if err != nil { - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err + + if err := updateSecret(context.Background(), cl, authSectretRef.Name, ns, newValues); err != nil { + return nil, err } - lastKey := len(foundApplicationKeys) - 1 - authSecret.ApplicationKey = fmt.Sprint(foundApplicationKeys[lastKey].Value) + } else { + // Nothing available raise error now + return nil, fmt.Errorf("no ApplicationKey available in secret and generate secret is set to false") } } + return &AuthSecret{ApplicationKey: applicationKeyStr}, nil +} - // get the current values and update the secret - ApplicationAuthSecret := &corev1.Secret{} - err := r.Client().Get(r.Context(), types.NamespacedName{ - Name: applicationAuth.Spec.AuthSecretRef.Name, - Namespace: applicationAuth.Namespace, - }, ApplicationAuthSecret) +type oidcAuthMode struct { + logger logr.Logger +} + +func (o *oidcAuthMode) Sync(threescaleClient *threescaleapi.ThreeScaleClient, developerAccountID int64, applicationID int64, authSecret AuthSecret) error { + // get the existing value from the portal + applicationKeys, err := threescaleClient.ApplicationKeys(developerAccountID, applicationID) if err != nil { - // Handle errors gracefully, e.g., log and return or retry - r.Logger().Error(err, "Failed to get existing ApplicationAuthSecret") - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err + return err } - newData := ApplicationAuthSecret.Data - newValues := map[string][]byte{ - UserKey: []byte(authSecret.UserKey), - ApplicationID: []byte(authSecret.ApplicationID), - ApplicationKey: []byte(authSecret.ApplicationKey), + + // pre-existing keys + if len(applicationKeys) > 0 { + // Nothing to do, return early + if applicationKeys[0].Value == authSecret.ClientSecret { + return nil + } + + // if the key is not match, delete it + if err := threescaleClient.DeleteApplicationKey(developerAccountID, applicationID, applicationKeys[0].Value); err != nil { + return err + } } - for key, value := range newValues { - newData[key] = value + + if _, err = threescaleClient.CreateApplicationKey(developerAccountID, applicationID, authSecret.ClientSecret); err != nil { + return err + } + return nil +} + +func (o *oidcAuthMode) SecretReferenceSource(cl client.Client, ns string, authSectretRef *corev1.LocalObjectReference, generateSecret bool) (*AuthSecret, error) { + secretSource := helper.NewSecretSource(cl, ns) + clientSecretStr, err := secretSource.RequiredFieldValueFromRequiredSecret(authSectretRef.Name, ClientSecret) + if err != nil { + return nil, err + } + + if clientSecretStr == "" { + if generateSecret { + clientSecretStr = rand.String(16) + } + newValues := map[string][]byte{ + ClientSecret: []byte(clientSecretStr), + } + + if err := updateSecret(context.Background(), cl, authSectretRef.Name, ns, newValues); err != nil { + return nil, err + } } + return &AuthSecret{ClientSecret: clientSecretStr}, nil +} + +func (r *ApplicationAuthReconciler) reconcileStatus(resource *capabilitiesv1beta1.ApplicationAuth, err error, logger logr.Logger) (ctrl.Result, error) { + statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, resource, err) + statusResult, statusErr := statusReconciler.Reconcile() - ApplicationAuthSecret.Data = newData - err = r.Client().Update(r.Context(), ApplicationAuthSecret) if err != nil { - r.Logger().Error(err, "Failed to update ApplicationAuthSecret") - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, err) - return statusReconciler, err + return ctrl.Result{}, err + } + + // Reconcile status first as the reconcilerError might need to be updated to the status section of the CR before requeueing + if statusErr != nil { + return ctrl.Result{}, statusErr } - statusReconciler := NewApplicationAuthStatusReconciler(r.BaseReconciler, applicationAuth, nil) - return statusReconciler, nil + + if statusResult.Requeue { + logger.Info("Reconciling status not finished. Requeueing.") + return statusResult, nil + } + + return ctrl.Result{}, nil } -func authSecretReferenceSource(cl client.Client, ns string, authSectretRef *corev1.LocalObjectReference, logger logr.Logger) *AuthSecret { - if authSectretRef != nil { - logger.Info("LookupAuthSecret", "ns", ns, "authSecretRef", authSectretRef) - secretSource := helper.NewSecretSource(cl, ns) - userKeyStr, err := secretSource.RequiredFieldValueFromRequiredSecret(authSectretRef.Name, UserKey) - if err != nil { - userKeyStr = "" - } - applicationKeyStr, err := secretSource.RequiredFieldValueFromRequiredSecret(authSectretRef.Name, ApplicationKey) - if err != nil { - applicationKeyStr = "" +func checkApplicationResources(applicationAuthResource *capabilitiesv1beta1.ApplicationAuth, applicationResource *capabilitiesv1beta1.Application) error { + errors := field.ErrorList{} + + specFldPath := field.NewPath("spec") + applicationFldPath := specFldPath.Child("applicationCRName") + + if applicationResource.Status.ID == nil { + errors = append(errors, field.Invalid(applicationFldPath, applicationAuthResource.Spec.ApplicationCRName, "applicationCR name doesnt have a valid application reference")) + + return &helper.SpecFieldError{ + ErrorType: helper.OrphanError, + FieldErrorList: errors, } + } + + return nil +} + +func updateSecret(ctx context.Context, client client.Client, name string, namespace string, values map[string][]byte) error { + // get the current values and update the secret + secret := &corev1.Secret{} + err := client.Get(ctx, types.NamespacedName{ + Name: name, + Namespace: namespace, + }, secret) + if err != nil { + // Handle errors gracefully, e.g., log and return or retry + return err + } + + newData := secret.Data + + for key, value := range values { + newData[key] = value + } + + secret.Data = newData - return &AuthSecret{UserKey: userKeyStr, ApplicationKey: applicationKeyStr} + if err = client.Update(ctx, secret); err != nil { + return err } return nil diff --git a/controllers/capabilities/applicationauth_controller_test.go b/controllers/capabilities/applicationauth_controller_test.go index aab81d5b3..cc8d0cfbf 100644 --- a/controllers/capabilities/applicationauth_controller_test.go +++ b/controllers/capabilities/applicationauth_controller_test.go @@ -1,115 +1,390 @@ package controllers import ( - "bytes" - "io" + "context" + "encoding/json" "net/http" - "reflect" + "net/http/httptest" + "slices" "strconv" + "strings" "testing" capabilitiesv1beta1 "github.com/3scale/3scale-operator/apis/capabilities/v1beta1" - "github.com/3scale/3scale-operator/pkg/reconcilers" + "github.com/3scale/3scale-operator/pkg/helper" threescaleapi "github.com/3scale/3scale-porta-go-client/client" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" + logf "sigs.k8s.io/controller-runtime/pkg/log" ) -func TestApplicationAuthReconciler_applicationAuthReconciler(t *testing.T) { - ap, _ := threescaleapi.NewAdminPortalFromStr("https://3scale-admin.test.3scale.net") - applicationKey := "4efd48e3e2ecfdea1fc21eeddf0610b9" +func TestApplicationAuthReconciler_syncApplicationAuth(t *testing.T) { + logger := logf.Log.WithName("applicationAuth") appID := int64(3) userAccountID := int64(3) - applicationUpdate := &threescaleapi.ApplicationElem{ - Application: threescaleapi.Application{ - UserAccountID: strconv.FormatInt(userAccountID, 10), - ID: appID, - AppName: "newName", - }, - } - applicationKeyCreate := &threescaleapi.ApplicationElem{ - Application: threescaleapi.Application{ - ID: appID, - }, - } - applicationKeyList := &threescaleapi.ApplicationKeysElem{ - Keys: []threescaleapi.ApplicationKeyWrapper{ - { - Key: threescaleapi.ApplicationKey{ - Value: applicationKey, - }, - }, - }, - } - type fields struct { - BaseReconciler *reconcilers.BaseReconciler - } - type args struct { - applicationAuth *capabilitiesv1beta1.ApplicationAuth - developerAccount *capabilitiesv1beta1.DeveloperAccount - application *capabilitiesv1beta1.Application - product *capabilitiesv1beta1.Product - authSecret AuthSecret - threescaleClient *threescaleapi.ThreeScaleClient - } tests := []struct { - name string - fields fields - args args - want *ApplicationAuthStatusReconciler - wantErr bool + name string + mockServer *mockApplicationAuthServer + authMode string + authSecret AuthSecret + expectedKey string + wantErr bool }{ { - name: "Test generate secret", - fields: fields{ - BaseReconciler: getBaseReconciler(getEmptyAuthSecretObj()), + name: "Empty userkey with empty secret", + mockServer: &mockApplicationAuthServer{ + authMode: "1", + userKey: "", + userAccountID: appID, + appID: userAccountID, }, - args: args{ - applicationAuth: getApplicationAuthGenerateSecret(), - application: getApplicationCR(), - product: getProductCR(), - developerAccount: getApplicationDeveloperAccount(), - authSecret: getEmptyAuthSecret(), - threescaleClient: threescaleapi.NewThreeScale(ap, "test", mockHttpApplicationAuthClient(applicationUpdate, applicationKeyCreate, applicationKeyList)), + authMode: "1", + authSecret: getEmptyAuthSecret(), + expectedKey: "", + wantErr: false, + }, + { + name: "update empty user_key with value from secret", + mockServer: &mockApplicationAuthServer{ + authMode: "1", + userKey: "", + userAccountID: appID, + appID: userAccountID, }, - want: NewApplicationAuthStatusReconciler(getBaseReconciler(getApplicationAuthGenerateSecret()), getApplicationAuthGenerateSecret(), nil), - wantErr: false, + authMode: "1", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, }, { - name: "Test populated secret", - fields: fields{ - BaseReconciler: getBaseReconciler(getAuthSecretObj()), + name: "update existing user_key with value from secret", + mockServer: &mockApplicationAuthServer{ + authMode: "1", + userKey: "initalkey", + userAccountID: appID, + appID: userAccountID, }, - args: args{ - applicationAuth: getApplicationAuth(), - application: getApplicationCR(), - product: getProductCR(), - developerAccount: getApplicationDeveloperAccount(), - authSecret: getAuthSecret(), - threescaleClient: threescaleapi.NewThreeScale(ap, "test", mockHttpApplicationAuthClient(applicationUpdate, applicationKeyCreate, applicationKeyList)), + authMode: "1", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, + }, + { + name: "update existing user_key with the same value should not return error", + mockServer: &mockApplicationAuthServer{ + authMode: "1", + userKey: "testkey", + userAccountID: appID, + appID: userAccountID, }, - want: NewApplicationAuthStatusReconciler(getBaseReconciler(getApplicationAuth()), getApplicationAuth(), nil), + authMode: "1", + authSecret: getAuthSecret(), expectedKey: "testkey", wantErr: false, }, - // TODO: Add test cases. + { + name: "returns error with empty application_key with empty secret", + mockServer: &mockApplicationAuthServer{ + authMode: "2", + keys: []string{}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "2", + authSecret: getEmptyAuthSecret(), + expectedKey: "", + wantErr: true, + }, + { + name: "update existing app_key with value from secret", + mockServer: &mockApplicationAuthServer{ + authMode: "2", + keys: []string{"initalkey"}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "2", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, + }, + { + name: "update existing app_key with the same value should not return error", + mockServer: &mockApplicationAuthServer{ + authMode: "2", + keys: []string{"testkey"}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "2", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, + }, + { + name: "returns error with empty client_secret with empty secret", + mockServer: &mockApplicationAuthServer{ + authMode: "oidc", + keys: []string{}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "oidc", + authSecret: getEmptyAuthSecret(), + expectedKey: "", + wantErr: true, + }, + { + name: "update existing client_secret with value from secret", + mockServer: &mockApplicationAuthServer{ + authMode: "oidc", + keys: []string{"initalkey"}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "oidc", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, + }, + { + name: "update existing client_secret with the same value should not return error", + mockServer: &mockApplicationAuthServer{ + authMode: "oidc", + keys: []string{"testkey"}, + userAccountID: appID, + appID: userAccountID, + }, + authMode: "oidc", + authSecret: getAuthSecret(), + expectedKey: "testkey", + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := &ApplicationAuthReconciler{ - BaseReconciler: tt.fields.BaseReconciler, + srv := tt.mockServer.GetServer() + defer srv.Close() + + ap, err := threescaleapi.NewAdminPortalFromStr(srv.URL) + if err != nil { + t.Fatalf("unexpected error = %v", err) + } + threescaleClient := threescaleapi.NewThreeScale(ap, "test", srv.Client()) + + controller, err := GetAuthController(tt.authMode, logger) + if err != nil { + t.Fatalf("GetAuthController() failed with error = %v, wantErr %v", err, tt.wantErr) } - got, err := r.applicationAuthReconciler(tt.args.applicationAuth, tt.args.developerAccount, tt.args.application, tt.args.product, tt.args.authSecret, tt.args.threescaleClient) + + err = controller.Sync(threescaleClient, userAccountID, appID, tt.authSecret) if (err != nil) != tt.wantErr { - t.Errorf("applicationAuthReconciler() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("ApplicationAuth Sync() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got.reconcileError, tt.want.reconcileError) { - t.Errorf("applicationAuthReconciler() got = %v, want %v", got.reconcileError, tt.want.reconcileError) + + newKey := tt.mockServer.GetKey(tt.authMode) + if newKey != tt.expectedKey { + t.Fatalf("mismatch keys, expected: %s - got: %s", tt.expectedKey, newKey) } - if !reflect.DeepEqual(got.resource, tt.want.resource) { - t.Errorf("applicationAuthReconciler() got = %v, want %v", got.resource, tt.want.resource) + + if (err != nil) != tt.wantErr { + t.Fatalf("ApplicationAuth Sync() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestApplicationAuthReconciler_authSecretReferenceSource(t *testing.T) { + logger := logf.Log.WithName("applicationAuth") + ns := "test" + + tests := []struct { + name string + authMode string + generateSecret bool + secretData map[string][]byte + secretRef string + wantErr bool + err string + }{ + { + name: "return error with non-exist secret", + authMode: "1", + generateSecret: false, + secretData: map[string][]byte{}, + secretRef: "unknown", + wantErr: true, + err: `secrets "unknown" not found`, + }, + { + name: "return error when secret is empty", + authMode: "1", + generateSecret: false, + secretData: map[string][]byte{}, + wantErr: true, + err: "secret field 'UserKey' is required in secret 'test'", + }, + { + name: "return error when secret is empty", + authMode: "1", + generateSecret: true, + secretData: map[string][]byte{}, + wantErr: true, + err: "secret field 'UserKey' is required in secret 'test'", + }, + { + name: "generate user_key when secret is empty", + authMode: "1", + generateSecret: true, + secretData: map[string][]byte{"UserKey": []byte("")}, + wantErr: false, + err: "", + }, + { + name: "returns error when user_key is empty and generateSecret is off", + authMode: "1", + generateSecret: false, + secretData: map[string][]byte{"UserKey": []byte("")}, + wantErr: true, + err: "no UserKey available in secret and generate secret is set to false", + }, + { + name: "use user_key value in secret is empty", + authMode: "1", + generateSecret: false, + secretData: map[string][]byte{"UserKey": []byte("testkey")}, + wantErr: false, + err: "", + }, + { + name: "return error when secret is empty", + authMode: "2", + generateSecret: true, + secretData: map[string][]byte{}, + wantErr: true, + err: "secret field 'ApplicationKey' is required in secret 'test'", + }, + { + name: "return error when secret is empty", + authMode: "2", + generateSecret: false, + secretData: map[string][]byte{}, + wantErr: true, + err: "secret field 'ApplicationKey' is required in secret 'test'", + }, + { + name: "generate app_key when secret is empty", + authMode: "2", + generateSecret: true, + secretData: map[string][]byte{"ApplicationKey": []byte("")}, + wantErr: false, + err: "", + }, + { + name: "returns error when app_key is empty and generateSecret is off", + authMode: "2", + generateSecret: false, + secretData: map[string][]byte{"ApplicationKey": []byte("")}, + wantErr: true, + err: "no ApplicationKey available in secret and generate secret is set to false", + }, + { + name: "use app_key value in secret is empty", + authMode: "2", + generateSecret: true, + secretData: map[string][]byte{"ApplicationKey": []byte("testkey")}, + wantErr: false, + err: "", + }, + { + name: "return error when secret is empty", + authMode: "oidc", + generateSecret: true, + secretData: map[string][]byte{}, + wantErr: true, + err: "secret field 'ClientSecret' is required in secret 'test'", + }, + { + name: "generate client_secret when secret is empty", + authMode: "oidc", + generateSecret: true, + secretData: map[string][]byte{"ClientSecret": []byte("")}, + wantErr: false, + err: "", + }, + { + name: "use client_secret value in secret", + authMode: "oidc", + generateSecret: true, + secretData: map[string][]byte{"ClientSecret": []byte("testkey")}, + wantErr: false, + err: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "test", + }, + Immutable: nil, + Data: tt.secretData, + StringData: nil, + Type: "", + } + + secretRef := &corev1.LocalObjectReference{ + Name: "test", + } + + if tt.secretRef != "" { + secretRef.Name = tt.secretRef + } + + reconciler := getBaseReconciler(secret) + client := reconciler.Client() + controller, err := GetAuthController(tt.authMode, logger) + if err != nil { + t.Fatalf("authSecretReferenceSource() unexpected error = %v", err) + } + + authSecret, err := controller.SecretReferenceSource(client, ns, secretRef, tt.generateSecret) + if (err != nil) != tt.wantErr { + t.Fatalf("authSecretReferenceSource() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + if tt.err != err.Error() { + t.Fatalf("authSecretReferenceSource() error = %v, wantErr %v", err, tt.err) + } + } else { + newSecret := &corev1.Secret{} + err = client.Get(context.Background(), types.NamespacedName{ + Name: secretRef.Name, + Namespace: ns, + }, newSecret) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + switch tt.authMode { + case "1": + if authSecret.UserKey != string(newSecret.Data["UserKey"]) { + t.Fatalf("mismatch user_key expected = '%s', got '%s'", authSecret.UserKey, newSecret.Data["UserKey"]) + } + case "2": + if authSecret.ApplicationKey != string(newSecret.Data["ApplicationKey"]) { + t.Fatalf("mismatch user_key expected = '%s', got '%s'", authSecret.ApplicationKey, newSecret.Data["ApplicationKey"]) + } + case "oidc": + if authSecret.ClientSecret != string(newSecret.Data[ClientSecret]) { + t.Fatalf("mismatch user_key expected = '%s', got '%s'", authSecret.ClientSecret, newSecret.Data[ClientSecret]) + } + } } }) } @@ -151,23 +426,6 @@ func getApplicationAuth() (CR *capabilitiesv1beta1.ApplicationAuth) { return CR } -func getEmptyAuthSecretObj() *corev1.Secret { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", - }, - Immutable: nil, - Data: map[string][]byte{ - "UserKey": []byte(""), - "ApplicationKey": []byte(""), - }, - StringData: nil, - Type: "", - } - return secret -} - func getEmptyAuthSecret() AuthSecret { authSecret := AuthSecret{ UserKey: "", @@ -177,58 +435,174 @@ func getEmptyAuthSecret() AuthSecret { return authSecret } -func getAuthSecretObj() *corev1.Secret { - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test", - Namespace: "test", - }, - Immutable: nil, - Data: map[string][]byte{ - "UserKey": []byte("testkey"), - "ApplicationKey": []byte("testkey"), - }, - StringData: nil, - Type: "", - } - return secret -} - func getAuthSecret() AuthSecret { authSecret := AuthSecret{ UserKey: "testkey", ApplicationKey: "testkey", ApplicationID: "", + ClientSecret: "testkey", } return authSecret } -func mockHttpApplicationAuthClient(applicationUpdate *threescaleapi.ApplicationElem, applicationKeyCreate *threescaleapi.ApplicationElem, applicationKeyList *threescaleapi.ApplicationKeysElem) *http.Client { - // override httpClient - httpClient := NewTestClient(func(req *http.Request) *http.Response { - if req.Method == http.MethodPut && req.URL.Path == "/admin/api/accounts/3/applications/3.json" { - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewBuffer(responseBody(applicationUpdate))), +type mockApplicationAuthServer struct { + authMode string + appID int64 + userAccountID int64 + userKey string + keys []string +} + +func (m *mockApplicationAuthServer) GetServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("GET /admin/api/accounts/{accoundID}/applications/{applicationID}", m.applicationHandler) + mux.HandleFunc("PUT /admin/api/accounts/{accoundID}/applications/{applicationID}", m.applicationHandler) + mux.HandleFunc("GET /admin/api/accounts/{accoundID}/applications/{applicationID}/keys.json", m.applicationKeysHandler) + mux.HandleFunc("DELETE /admin/api/accounts/{accoundID}/applications/{applicationID}/keys/{key}", m.applicationKeysHandler) + mux.HandleFunc("POST /admin/api/accounts/{accoundID}/applications/{applicationID}/keys.json", m.applicationKeysHandler) + + return httptest.NewServer(mux) +} + +func (m *mockApplicationAuthServer) GetKey(mode string) string { + switch mode { + case "1": + return m.userKey + case "2", "oidc": + return strings.Join(m.keys, ",") + default: + return "" + } +} + +func (m *mockApplicationAuthServer) applicationHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPut { + _ = r.ParseForm() + userKey := r.FormValue("user_key") + if userKey != "" { + if userKey == m.userKey { + errorResponse(w, "user_key", []string{"has already been taken"}) + return + } else { + m.userKey = userKey } } - if req.Method == http.MethodPost && req.URL.Path == "/admin/api/accounts/3/applications/3/keys.json" { - return &http.Response{ - StatusCode: http.StatusCreated, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewBuffer(responseBody(applicationKeyCreate))), - } + } + + data := threescaleapi.ApplicationElem{ + Application: threescaleapi.Application{ + UserAccountID: strconv.FormatInt(m.userAccountID, 10), + ID: m.appID, + AppName: "newName", + UserKey: m.userKey, + }, + } + + json, err := json.Marshal(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(json) +} + +func (m *mockApplicationAuthServer) applicationKeysHandler(w http.ResponseWriter, r *http.Request) { + var keyLimit int + + switch r.Method { + case http.MethodPost: + _ = r.ParseForm() + key := r.FormValue("key") + + if len(key) < 5 { + errorResponse(w, "value", []string{"is too short (minimum is 5 characters)"}) + return + } + + // if key already existed, returns error + if helper.ArrayContains(m.keys, key) { + errorResponse(w, "value", []string{"has already been taken"}) + return + } + + if m.authMode == "2" { + keyLimit = 5 + } else if m.authMode == "oidc" { + keyLimit = 1 } - if req.Method == http.MethodGet && req.URL.Path == "/admin/api/accounts/3/applications/3/keys.json" { - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(bytes.NewBuffer(responseBody(applicationKeyList))), + + // Check if the current length does not exceed 5 keys limit + if len(m.keys) == keyLimit { + errorResponse(w, "base", []string{"Limit reached"}) + return + } + + m.keys = append(m.keys, key) + + data := &threescaleapi.ApplicationElem{ + Application: threescaleapi.Application{ + UserAccountID: strconv.FormatInt(m.userAccountID, 10), + ID: m.appID, + AppName: "newName", + }, + } + + json, err := json.Marshal(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(json) + case http.MethodDelete: + key := strings.TrimSuffix(r.PathValue("key"), ".json") + + newKeys := slices.DeleteFunc(m.keys, func(existingKey string) bool { + return existingKey == key + }) + m.keys = newKeys + return + case http.MethodGet: + keysObj := []threescaleapi.ApplicationKeyWrapper{} + + for _, key := range m.keys { + keyObj := threescaleapi.ApplicationKeyWrapper{ + Key: threescaleapi.ApplicationKey{ + Value: key, + }, } + keysObj = append(keysObj, keyObj) + } + + data := &threescaleapi.ApplicationKeysElem{ + Keys: keysObj, + } + + json, err := json.Marshal(data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - return nil - }) - return httpClient + w.Header().Add("Content-Type", "application/json") + _, _ = w.Write(json) + } +} + +func errorResponse(w http.ResponseWriter, key string, value []string) { + errObj := struct { + Errors map[string][]string `json:"errors"` + }{ + Errors: map[string][]string{key: value}, + } + + data, err := json.Marshal(errObj) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + http.Error(w, string(data), http.StatusUnprocessableEntity) } diff --git a/main.go b/main.go index 75559a402..37148c66e 100644 --- a/main.go +++ b/main.go @@ -445,9 +445,9 @@ func main() { if err = (&capabilitiescontroller.ApplicationAuthReconciler{ BaseReconciler: reconcilers.NewBaseReconciler( context.Background(), mgr.GetClient(), mgr.GetScheme(), mgr.GetAPIReader(), - ctrl.Log.WithName("controllers").WithName("Application"), + ctrl.Log.WithName("controllers").WithName("ApplicationAuth"), discoveryApplicationAuth, - mgr.GetEventRecorderFor("Application")), + mgr.GetEventRecorderFor("ApplicationAuth")), }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Application") os.Exit(1)