diff --git a/api/bases/placement.openstack.org_placementapis.yaml b/api/bases/placement.openstack.org_placementapis.yaml index 167944dc..73ef0098 100644 --- a/api/bases/placement.openstack.org_placementapis.yaml +++ b/api/bases/placement.openstack.org_placementapis.yaml @@ -57,6 +57,14 @@ spec: description: APITimeout for HAProxy, Apache minimum: 10 type: integer + auth: + description: Auth - Parameters related to authentication + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: PlacementAPI Container Image URL (will be set to environmental default if empty) diff --git a/api/v1beta1/placementapi_types.go b/api/v1beta1/placementapi_types.go index 21b7fd96..3354e3b5 100644 --- a/api/v1beta1/placementapi_types.go +++ b/api/v1beta1/placementapi_types.go @@ -17,14 +17,14 @@ limitations under the License. package v1beta1 import ( + topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" "github.com/openstack-k8s-operators/lib-common/modules/common/service" "github.com/openstack-k8s-operators/lib-common/modules/common/tls" "github.com/openstack-k8s-operators/lib-common/modules/common/util" - topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" - "k8s.io/apimachinery/pkg/util/validation/field" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" ) const ( @@ -126,6 +126,11 @@ type PlacementAPISpecCore struct { // TLS - Parameters related to the TLS TLS tls.API `json:"tls,omitempty"` + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // Auth - Parameters related to authentication + Auth AuthSpec `json:"auth,omitempty"` + // +kubebuilder:validation:Optional // TopologyRef to apply the Topology defined by the associated CR referenced // by name @@ -139,6 +144,14 @@ type APIOverrideSpec struct { Service map[service.Endpoint]service.RoutedOverrideSpec `json:"service,omitempty"` } +// AuthSpec defines authentication parameters +type AuthSpec struct { + // +kubebuilder:validation:Optional + // +operator-sdk:csv:customresourcedefinitions:type=spec + // ApplicationCredentialSecret - Secret containing Application Credential ID and Secret + ApplicationCredentialSecret string `json:"applicationCredentialSecret,omitempty"` +} + // PasswordSelector to identify the DB and AdminUser password from the Secret type PasswordSelector struct { // +kubebuilder:validation:Optional diff --git a/api/v1beta1/placementapi_webhook.go b/api/v1beta1/placementapi_webhook.go index 677eee9c..c1498227 100644 --- a/api/v1beta1/placementapi_webhook.go +++ b/api/v1beta1/placementapi_webhook.go @@ -68,7 +68,6 @@ func (spec *PlacementAPISpec) Default() { if spec.APITimeout == 0 { spec.APITimeout = placementAPIDefaults.APITimeout } - } // Default - set defaults for this PlacementAPI core spec (this version is used by the OpenStackControlplane webhook) diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 09d35e50..8c4b13be 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -49,6 +49,21 @@ func (in *APIOverrideSpec) DeepCopy() *APIOverrideSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthSpec) DeepCopyInto(out *AuthSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthSpec. +func (in *AuthSpec) DeepCopy() *AuthSpec { + if in == nil { + return nil + } + out := new(AuthSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PasswordSelector) DeepCopyInto(out *PasswordSelector) { *out = *in @@ -189,6 +204,7 @@ func (in *PlacementAPISpecCore) DeepCopyInto(out *PlacementAPISpecCore) { } in.Override.DeepCopyInto(&out.Override) in.TLS.DeepCopyInto(&out.TLS) + out.Auth = in.Auth if in.TopologyRef != nil { in, out := &in.TopologyRef, &out.TopologyRef *out = new(topologyv1beta1.TopoRef) diff --git a/config/crd/bases/placement.openstack.org_placementapis.yaml b/config/crd/bases/placement.openstack.org_placementapis.yaml index 167944dc..73ef0098 100644 --- a/config/crd/bases/placement.openstack.org_placementapis.yaml +++ b/config/crd/bases/placement.openstack.org_placementapis.yaml @@ -57,6 +57,14 @@ spec: description: APITimeout for HAProxy, Apache minimum: 10 type: integer + auth: + description: Auth - Parameters related to authentication + properties: + applicationCredentialSecret: + description: ApplicationCredentialSecret - Secret containing Application + Credential ID and Secret + type: string + type: object containerImage: description: PlacementAPI Container Image URL (will be set to environmental default if empty) diff --git a/config/manifests/bases/placement-operator.clusterserviceversion.yaml b/config/manifests/bases/placement-operator.clusterserviceversion.yaml index add6cd12..2ae6b216 100644 --- a/config/manifests/bases/placement-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/placement-operator.clusterserviceversion.yaml @@ -24,6 +24,13 @@ spec: kind: PlacementAPI name: placementapis.placement.openstack.org specDescriptors: + - description: Auth - Parameters related to authentication + displayName: Auth + path: auth + - description: ApplicationCredentialSecret - Secret containing Application Credential + ID and Secret + displayName: Application Credential Secret + path: auth.applicationCredentialSecret - description: TLS - Parameters related to the TLS displayName: TLS path: tls diff --git a/internal/controller/placementapi_controller.go b/internal/controller/placementapi_controller.go index db19bd55..e4fe1990 100644 --- a/internal/controller/placementapi_controller.go +++ b/internal/controller/placementapi_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" + "errors" "fmt" "maps" "time" @@ -68,6 +69,12 @@ import ( k8s_errors "k8s.io/apimachinery/pkg/api/errors" ) +// Static errors for Application Credential handling +var ( + ErrACSecretNotFound = errors.New("ApplicationCredential secret not found") + ErrACSecretMissingKeys = errors.New("ApplicationCredential secret missing required keys") +) + type conditionUpdater interface { Set(c *condition.Condition) MarkTrue(t condition.Type, messageFormat string, messageArgs ...any) @@ -849,6 +856,7 @@ const ( tlsAPIInternalField = ".spec.tls.api.internal.secretName" tlsAPIPublicField = ".spec.tls.api.public.secretName" topologyField = ".spec.topologyRef.Name" + authAppCredSecretField = ".spec.auth.applicationCredentialSecret" // #nosec G101 ) var allWatchFields = []string{ @@ -857,6 +865,7 @@ var allWatchFields = []string{ tlsAPIInternalField, tlsAPIPublicField, topologyField, + authAppCredSecretField, } // SetupWithManager sets up the controller with the Manager. @@ -921,6 +930,18 @@ func (r *PlacementAPIReconciler) SetupWithManager(mgr ctrl.Manager) error { return err } + // index authAppCredSecretField + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &placementv1.PlacementAPI{}, authAppCredSecretField, func(rawObj client.Object) []string { + // Extract the application credential secret name from the spec, if one is provided + cr := rawObj.(*placementv1.PlacementAPI) + if cr.Spec.Auth.ApplicationCredentialSecret == "" { + return nil + } + return []string{cr.Spec.Auth.ApplicationCredentialSecret} + }); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). For(&placementv1.PlacementAPI{}). Owns(&mariadbv1.MariaDBDatabase{}). @@ -1379,6 +1400,30 @@ func (r *PlacementAPIReconciler) generateServiceConfigMaps( ), } + templateParameters["UseApplicationCredentials"] = false + // Try to get Application Credential for this service + if instance.Spec.Auth.ApplicationCredentialSecret != "" { + acSecretObj, _, err := secret.GetSecret(ctx, h, instance.Spec.Auth.ApplicationCredentialSecret, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + h.GetLogger().Info("ApplicationCredential secret not found, waiting", "secret", instance.Spec.Auth.ApplicationCredentialSecret) + return fmt.Errorf("%w: %s", ErrACSecretNotFound, instance.Spec.Auth.ApplicationCredentialSecret) + } + h.GetLogger().Error(err, "Failed to get ApplicationCredential secret", "secret", instance.Spec.Auth.ApplicationCredentialSecret) + return err + } + acID, okID := acSecretObj.Data[keystonev1.ACIDSecretKey] + acSecretData, okSecret := acSecretObj.Data[keystonev1.ACSecretSecretKey] + if !okID || len(acID) == 0 || !okSecret || len(acSecretData) == 0 { + h.GetLogger().Info("ApplicationCredential secret missing required keys", "secret", instance.Spec.Auth.ApplicationCredentialSecret) + return fmt.Errorf("%w: %s", ErrACSecretMissingKeys, instance.Spec.Auth.ApplicationCredentialSecret) + } + templateParameters["UseApplicationCredentials"] = true + templateParameters["ACID"] = string(acID) + templateParameters["ACSecret"] = string(acSecretData) + h.GetLogger().Info("Using ApplicationCredentials auth", "secret", instance.Spec.Auth.ApplicationCredentialSecret) + } + // create httpd vhost template parameters httpdVhostConfig := map[string]any{} for _, endpt := range []service.Endpoint{service.EndpointInternal, service.EndpointPublic} { diff --git a/templates/placementapi/config/placement.conf b/templates/placementapi/config/placement.conf index 71295afa..114e956a 100644 --- a/templates/placementapi/config/placement.conf +++ b/templates/placementapi/config/placement.conf @@ -15,17 +15,23 @@ connection = {{ .DatabaseConnection }} auth_strategy = keystone [keystone_authtoken] -project_domain_name = Default -user_domain_name = Default -project_name = service +www_authenticate_uri = {{ .KeystonePublicURL }} +auth_url = {{ .KeystoneInternalURL }} +{{ if .UseApplicationCredentials -}} +auth_type = v3applicationcredential +application_credential_id = {{ .ACID }} +application_credential_secret = {{ .ACSecret }} +{{ else -}} +auth_type = password username = {{ .ServiceUser }} password = {{ .PlacementPassword }} +user_domain_name = Default +project_domain_name = Default +project_name = service +{{- end }} {{ if (index . "Region") -}} region_name = {{ .Region }} {{ end -}} -www_authenticate_uri = {{ .KeystonePublicURL }} -auth_url = {{ .KeystoneInternalURL }} -auth_type = password interface = internal [oslo_policy] diff --git a/test/functional/placementapi_controller_test.go b/test/functional/placementapi_controller_test.go index b32309f5..93da1e90 100644 --- a/test/functional/placementapi_controller_test.go +++ b/test/functional/placementapi_controller_test.go @@ -374,11 +374,10 @@ var _ = Describe("PlacementAPI controller", func() { // Verify region_name is set in [keystone_authtoken] section // GetRegion() returns Status.Region, so check that Expect(keystoneAPI.Status.Region).ToNot(BeEmpty(), "KeystoneAPI should have a region set in status") - // The region_name should appear in the [keystone_authtoken] section - // It's conditionally rendered, so check it appears between password and www_authenticate_uri + // The region_name should appear in the [keystone_authtoken] section (before [oslo_policy]) Expect(conf).Should( MatchRegexp(fmt.Sprintf( - "password = .*\\nregion_name = %s\\n", keystoneAPI.Status.Region))) + `\[keystone_authtoken\][\s\S]*region_name = %s[\s\S]*\[oslo_policy\]`, keystoneAPI.Status.Region))) }) It("creates service account, role and rolebindig", func() { @@ -1470,4 +1469,61 @@ var _ = Describe("PlacementAPI reconfiguration", func() { }) + When("an ApplicationCredential is created for Placement", func() { + BeforeEach(func() { + DeferCleanup( + k8sClient.Delete, ctx, CreatePlacementAPISecret(names.Namespace, SecretName)) + keystoneAPIName := keystone.CreateKeystoneAPI(names.Namespace) + DeferCleanup(keystone.DeleteKeystoneAPI, keystoneAPIName) + + acSecretName := fmt.Sprintf("ac-%s-secret", placement.ServiceName) + acSecret := th.CreateSecret( + types.NamespacedName{Namespace: names.Namespace, Name: acSecretName}, + map[string][]byte{ + keystonev1.ACIDSecretKey: []byte("test-ac-id"), + keystonev1.ACSecretSecretKey: []byte("test-ac-secret"), + }, + ) + DeferCleanup(th.DeleteInstance, acSecret) + + spec := GetDefaultPlacementAPISpec() + spec["auth"] = map[string]any{ + "applicationCredentialSecret": acSecretName, + } + + DeferCleanup(th.DeleteInstance, CreatePlacementAPI(names.PlacementAPIName, spec)) + + DeferCleanup( + mariadb.DeleteDBService, + mariadb.CreateDBService( + names.Namespace, + GetDefaultPlacementAPISpec()["databaseInstance"].(string), + corev1.ServiceSpec{ + Ports: []corev1.ServicePort{{Port: 3306}}, + }, + ), + ) + mariadb.SimulateMariaDBDatabaseCompleted(names.MariaDBDatabaseName) + mariadb.SimulateMariaDBAccountCompleted(names.MariaDBAccount) + }) + + It("should render ApplicationCredential auth in placement.conf", func() { + configSecret := th.GetSecret(names.ConfigMapName) + conf := configSecret.Data["placement.conf"] + + // AC auth is configured + Expect(conf).To(ContainSubstring("auth_type = v3applicationcredential")) + Expect(conf).To(ContainSubstring("application_credential_id = test-ac-id")) + Expect(conf).To(ContainSubstring("application_credential_secret = test-ac-secret")) + + // Password auth fields should not be present + Expect(conf).NotTo(ContainSubstring("auth_type = password")) + Expect(conf).NotTo(ContainSubstring("username =")) + Expect(conf).NotTo(ContainSubstring("password =")) + Expect(conf).NotTo(ContainSubstring("project_name =")) + Expect(conf).NotTo(ContainSubstring("user_domain_name =")) + Expect(conf).NotTo(ContainSubstring("project_domain_name =")) + }) + }) + })