diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index c59d4bae..cf5f9f55 100644 --- a/apis/cluster/mysql/v1alpha1/user_types.go +++ b/apis/cluster/mysql/v1alpha1/user_types.go @@ -46,6 +46,17 @@ type UserParameters struct { // +optional ResourceOptions *ResourceOptions `json:"resourceOptions,omitempty"` + // AuthPlugin sets the mysql authentication plugin. + // If not specified (nil or empty string), the database server's default authentication plugin is used. + // This allows compatibility with different MySQL/MariaDB versions and their default authentication methods. + // Common plugins: caching_sha2_password (MySQL 8.0+), mysql_native_password, authentication_ldap_simple, etc. + // +optional + // +kubebuilder:validation:Pattern:=^([a-z]+_)+[a-z]+$ + AuthPlugin *string `json:"authPlugin,omitempty"` + + // UsePassword indicate whether the provided AuthPlugin requires setting a password, defaults to true + // +optional + UsePassword *bool `json:"usePassword,omitempty" default:"true"` // BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults to true // +optional BinLog *bool `json:"binlog,omitempty"` @@ -74,6 +85,9 @@ type ResourceOptions struct { type UserObservation struct { // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` + + // AuthPlugin represents the applied mysql authentication plugin + AuthPlugin *string `json:"authPlugin,omitempty"` } // +kubebuilder:object:root=true diff --git a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go index 546572bb..691c2a04 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -632,6 +632,11 @@ func (in *UserObservation) DeepCopyInto(out *UserObservation) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AuthPlugin != nil { + in, out := &in.AuthPlugin, &out.AuthPlugin + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserObservation. @@ -657,6 +662,16 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(ResourceOptions) (*in).DeepCopyInto(*out) } + if in.AuthPlugin != nil { + in, out := &in.AuthPlugin, &out.AuthPlugin + *out = new(string) + **out = **in + } + if in.UsePassword != nil { + in, out := &in.UsePassword, &out.UsePassword + *out = new(bool) + **out = **in + } if in.BinLog != nil { in, out := &in.BinLog, &out.BinLog *out = new(bool) diff --git a/apis/namespaced/mysql/v1alpha1/user_types.go b/apis/namespaced/mysql/v1alpha1/user_types.go index d8a8a1c8..a3d8e467 100644 --- a/apis/namespaced/mysql/v1alpha1/user_types.go +++ b/apis/namespaced/mysql/v1alpha1/user_types.go @@ -42,6 +42,18 @@ type UserParameters struct { // +optional PasswordSecretRef *xpv1.LocalSecretKeySelector `json:"passwordSecretRef,omitempty"` + // AuthPlugin specifies the authentication plugin to use for the user. + // If not specified (nil or empty string), the database server's default authentication plugin is used. + // Common values include "mysql_native_password", "caching_sha2_password", "authentication_ldap_simple". + // See https://dev.mysql.com/doc/refman/8.0/en/authentication-plugins.html + // +optional + // +kubebuilder:validation:Pattern:=^([a-z]+_)+[a-z]+$ + AuthPlugin *string `json:"authPlugin,omitempty"` + + // UsePassword indicate whether the provided AuthPlugin requires setting a password, defaults to true + // +optional + UsePassword *bool `json:"usePassword,omitempty" default:"true"` + // ResourceOptions sets account specific resource limits. // See https://dev.mysql.com/doc/refman/8.0/en/user-resources.html // +optional @@ -73,6 +85,9 @@ type ResourceOptions struct { // A UserObservation represents the observed state of a MySQL user. type UserObservation struct { + // AuthPlugin is the authentication plugin currently configured for the user + AuthPlugin *string `json:"authPlugin,omitempty"` + // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` } diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index b3cb77a2..6c2d6da6 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -740,6 +740,11 @@ func (in *UserList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UserObservation) DeepCopyInto(out *UserObservation) { *out = *in + if in.AuthPlugin != nil { + in, out := &in.AuthPlugin, &out.AuthPlugin + *out = new(string) + **out = **in + } if in.ResourceOptionsAsClauses != nil { in, out := &in.ResourceOptionsAsClauses, &out.ResourceOptionsAsClauses *out = make([]string, len(*in)) @@ -765,6 +770,16 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.LocalSecretKeySelector) **out = **in } + if in.AuthPlugin != nil { + in, out := &in.AuthPlugin, &out.AuthPlugin + *out = new(string) + **out = **in + } + if in.UsePassword != nil { + in, out := &in.UsePassword, &out.UsePassword + *out = new(bool) + **out = **in + } if in.ResourceOptions != nil { in, out := &in.ResourceOptions, &out.ResourceOptions *out = new(ResourceOptions) diff --git a/examples/cluster/mysql/user_with_auth_plugin.yaml b/examples/cluster/mysql/user_with_auth_plugin.yaml new file mode 100644 index 00000000..a0bb5488 --- /dev/null +++ b/examples/cluster/mysql/user_with_auth_plugin.yaml @@ -0,0 +1,52 @@ +--- +# Example: User with custom authentication plugin (e.g., LDAP) +# Some authentication plugins like authentication_ldap_simple don't require +# passwords, so usePassword is set to false +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-ldap-user +spec: + forProvider: + authPlugin: authentication_ldap_simple + usePassword: false # LDAP authentication doesn't use a MySQL password + providerConfigRef: + name: example +--- +# Example: User with specific authentication plugin and password +# For plugins that require passwords like caching_sha2_password +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-caching-sha2-user +spec: + forProvider: + authPlugin: caching_sha2_password + passwordSecretRef: + name: example-pw + namespace: default + key: password + writeConnectionSecretToRef: + name: example-sha2-connection-secret + namespace: default + providerConfigRef: + name: example +--- +# Example: User without authPlugin specified (uses database server default) +# This is the recommended approach for maximum compatibility +# across different MySQL/MariaDB versions +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-default-user +spec: + forProvider: + passwordSecretRef: + name: example-pw + namespace: default + key: password + writeConnectionSecretToRef: + name: example-default-connection-secret + namespace: default + providerConfigRef: + name: example diff --git a/examples/namespaced/mysql/user_with_auth_plugin.yaml b/examples/namespaced/mysql/user_with_auth_plugin.yaml new file mode 100644 index 00000000..c89d4a9b --- /dev/null +++ b/examples/namespaced/mysql/user_with_auth_plugin.yaml @@ -0,0 +1,51 @@ +--- +# Example: Namespaced User with custom authentication plugin (e.g., LDAP) +# Some authentication plugins like authentication_ldap_simple don't require +# passwords, so usePassword is set to false +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-ldap-user + namespace: default +spec: + forProvider: + authPlugin: authentication_ldap_simple + usePassword: false # LDAP authentication doesn't use a MySQL password + providerConfigRef: + name: example +--- +# Example: Namespaced User with specific authentication plugin and password +# For plugins that require passwords like caching_sha2_password +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-caching-sha2-user + namespace: default +spec: + forProvider: + authPlugin: caching_sha2_password + passwordSecretRef: + name: example-pw + key: password + writeConnectionSecretToRef: + name: example-sha2-connection-secret + providerConfigRef: + name: example +--- +# Example: Namespaced User without authPlugin specified (uses database server default) +# This is the recommended approach for maximum compatibility +# across different MySQL/MariaDB versions +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-default-user + namespace: default +spec: + forProvider: + passwordSecretRef: + name: example-pw + key: password + writeConnectionSecretToRef: + name: example-default-connection-secret + providerConfigRef: + name: example diff --git a/package/crds/mysql.sql.crossplane.io_users.yaml b/package/crds/mysql.sql.crossplane.io_users.yaml index 788540e1..532979f5 100644 --- a/package/crds/mysql.sql.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.crossplane.io_users.yaml @@ -71,6 +71,14 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authPlugin: + description: |- + AuthPlugin sets the mysql authentication plugin. + If not specified (nil or empty string), the database server's default authentication plugin is used. + This allows compatibility with different MySQL/MariaDB versions and their default authentication methods. + Common plugins: caching_sha2_password (MySQL 8.0+), mysql_native_password, authentication_ldap_simple, etc. + pattern: ^([a-z]+_)+[a-z]+$ + type: string binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -117,6 +125,10 @@ spec: connections to the server by an account type: integer type: object + usePassword: + description: UsePassword indicate whether the provided AuthPlugin + requires setting a password, defaults to true + type: boolean type: object managementPolicies: default: @@ -211,6 +223,10 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authPlugin: + description: AuthPlugin represents the applied mysql authentication + plugin + type: string resourceOptionsAsClauses: description: ResourceOptionsAsClauses represents the applied resource options diff --git a/package/crds/mysql.sql.m.crossplane.io_users.yaml b/package/crds/mysql.sql.m.crossplane.io_users.yaml index 3aab6808..bc6f08e1 100644 --- a/package/crds/mysql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_users.yaml @@ -57,6 +57,14 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authPlugin: + description: |- + AuthPlugin specifies the authentication plugin to use for the user. + If not specified (nil or empty string), the database server's default authentication plugin is used. + Common values include "mysql_native_password", "caching_sha2_password", "authentication_ldap_simple". + See https://dev.mysql.com/doc/refman/8.0/en/authentication-plugins.html + pattern: ^([a-z]+_)+[a-z]+$ + type: string binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -98,6 +106,10 @@ spec: connections to the server by an account type: integer type: object + usePassword: + description: UsePassword indicate whether the provided AuthPlugin + requires setting a password, defaults to true + type: boolean type: object managementPolicies: default: @@ -164,6 +176,10 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authPlugin: + description: AuthPlugin is the authentication plugin currently + configured for the user + type: string resourceOptionsAsClauses: description: ResourceOptionsAsClauses represents the applied resource options diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index dc65d910..02f00ff9 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -193,6 +193,7 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) observed := &v1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &v1alpha1.ResourceOptions{}, } @@ -200,7 +201,8 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -214,6 +216,7 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &observed.AuthPlugin, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -228,6 +231,7 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthPlugin = observed.AuthPlugin mg.SetConditions(xpv1.Available()) @@ -241,20 +245,26 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - pw, _, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalCreation{}, err - } + plugin := defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - if pw == "" { - pw, err = password.Generate() + var pw *string + if checkUsePassword(mg) { + userPassword, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err } + + if userPassword == "" { + userPassword, err = password.Generate() + if err != nil { + return managed.ExternalCreation{}, err + } + } + pw = &userPassword } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { + if err := c.executeCreateUserQuery(ctx, username, host, plugin, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -262,30 +272,32 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter mg.Status.AtProvider.ResourceOptionsAsClauses = ro } - return managed.ExternalCreation{ - ConnectionDetails: c.db.GetConnectionDetails(username, pw), - }, nil + if pw != nil { + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, *pw), + }, nil + } + + return managed.ExternalCreation{}, nil } -func (c *external) executeCreateUserQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, pw string) error { +func (c *external) executeCreateUserQuery(ctx context.Context, username string, host string, plugin string, resourceOptionsClauses []string, pw *string) error { + identifiedClause := buildIdentifiedClause(plugin, pw) + resourceOptions := "" if len(resourceOptionsClauses) != 0 { resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) } query := fmt.Sprintf( - "CREATE USER %s@%s IDENTIFIED BY %s%s", + "CREATE USER %s@%s %s%s", mysql.QuoteValue(username), mysql.QuoteValue(host), - mysql.QuoteValue(pw), + identifiedClause, resourceOptions, ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { - return err - } - - return nil + return mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}) } func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.ExternalUpdate, error) { @@ -318,29 +330,88 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalUpdate{}, err } - if len(connectionDetails) > 0 { - return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil + return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil +} + +// UpdatePassword updates the password and/or auth plugin for a user if either has changed +func (c *external) UpdatePassword(ctx context.Context, mg *v1alpha1.User, username string, host string) (managed.ConnectionDetails, error) { + pw, pwChanged, err := c.getPassword(ctx, mg) + if err != nil { + return nil, err + } + + pluginChanged := false + desiredPlugin := defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + if mg.Status.AtProvider.AuthPlugin != nil { + observedPlugin := defaultAuthPlugin(mg.Status.AtProvider.AuthPlugin) + pluginChanged = desiredPlugin != observedPlugin + } + + if !pwChanged && !pluginChanged { + return nil, nil } - return managed.ExternalUpdate{}, nil + if err := c.executeAlterUserQuery(ctx, username, host, desiredPlugin, pw); err != nil { + return nil, err + } + + if pwChanged { + return c.db.GetConnectionDetails(username, pw), nil + } + + return nil, nil } -func (c *external) UpdatePassword(ctx context.Context, cr *v1alpha1.User, username, host string) (managed.ConnectionDetails, error) { - pw, pwchanged, err := c.getPassword(ctx, cr) - if err != nil { - return managed.ConnectionDetails{}, err +// executeAlterUserQuery executes an ALTER USER statement to update password/plugin +func (c *external) executeAlterUserQuery(ctx context.Context, username string, host string, plugin string, pw string) error { + identifiedClause := buildIdentifiedClause(plugin, &pw) + if identifiedClause == "" { + // No password and no plugin means nothing to update + return nil } - if pwchanged { - query := fmt.Sprintf("ALTER USER %s@%s IDENTIFIED BY %s", mysql.QuoteValue(username), mysql.QuoteValue(host), mysql.QuoteValue(pw)) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { - return managed.ConnectionDetails{}, err + query := fmt.Sprintf("ALTER USER %s@%s %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + identifiedClause, + ) + + return mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}) +} + +// buildIdentifiedClause constructs the IDENTIFIED clause for CREATE/ALTER USER statements. +func buildIdentifiedClause(plugin string, pw *string) string { + if plugin == "" { + if pw != nil && *pw != "" { + return fmt.Sprintf("IDENTIFIED BY %s", mysql.QuoteValue(*pw)) } + return "" + } - return c.db.GetConnectionDetails(username, pw), nil + identifiedClause := fmt.Sprintf("IDENTIFIED WITH %s", plugin) + if pw != nil && *pw != "" { + identifiedClause += fmt.Sprintf(" BY %s", mysql.QuoteValue(*pw)) + } + return identifiedClause +} + +func checkUsePassword(mg *v1alpha1.User) bool { + if mg.Spec.ForProvider.UsePassword == nil { + return true } - return managed.ConnectionDetails{}, nil + return *mg.Spec.ForProvider.UsePassword +} + +func defaultAuthPlugin(authPlugin *string) string { + // nil or empty string means use the default plugin (let MySQL/MariaDB decide) + // This avoids hardcoding mysql_native_password which is deprecated in MySQL 8.0.34+ + // and not supported in MariaDB + if authPlugin == nil || *authPlugin == "" { + return "" + } + + return *authPlugin } func (c *external) Disconnect(ctx context.Context) error { @@ -361,6 +432,14 @@ func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.Exter } func upToDate(observed *v1alpha1.UserParameters, desired *v1alpha1.UserParameters) bool { + // Check auth plugin + observedPlugin := defaultAuthPlugin(observed.AuthPlugin) + desiredPlugin := defaultAuthPlugin(desired.AuthPlugin) + if observedPlugin != desiredPlugin { + return false + } + + // Check resource options if desired.ResourceOptions == nil { // Return true if there are no desired ResourceOptions return true diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index 97bb7a64..a4cf863a 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -28,6 +28,7 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" @@ -238,7 +239,17 @@ func TestObserve(t *testing.T) { reason: "We should return no error if we can successfully select our user", fields: fields{ db: mockDB{ - MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { return nil }, + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + // Set the auth plugin to empty string (database will use its default) + // This avoids hardcoding mysql_native_password which is deprecated/removed + if len(dest) >= 5 { + if plugin, ok := dest[4].(**string); ok { + emptyPlugin := "" + *plugin = &emptyPlugin + } + } + return nil + }, }, }, args: args{ @@ -477,6 +488,34 @@ func TestCreate(t *testing.T) { }, }, }, + "UserWithAnAuthPluginThatNotRequiresPassword": { + reason: "A user that uses an authentication plugin that does not require password should not receive connection details and no error should happen", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + UsePassword: ptr.To(false), + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{}, + }, + }, } for name, tc := range cases { @@ -554,6 +593,11 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, kube: &test.MockClient{ MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { @@ -584,6 +628,11 @@ func TestUpdate(t *testing.T) { meta.AnnotationKeyExternalName: "example", }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, }, want: want{ @@ -613,6 +662,11 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, kube: &test.MockClient{ MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { @@ -656,6 +710,11 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, kube: &test.MockClient{ MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { @@ -692,6 +751,65 @@ func TestUpdate(t *testing.T) { }, }, }, + "UpdatedResourceOptions": { + reason: "We should execute an SQL query if the resource options are not synced.", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + PasswordSecretRef: &xpv1.SecretKeySelector{ + SecretReference: xpv1.SecretReference{ + Name: "connection-secret", + }, + Key: xpv1.ResourceCredentialsSecretPasswordKey, + }, + ResourceOptions: &v1alpha1.ResourceOptions{ + MaxQueriesPerHour: ptr.To(10), + MaxUpdatesPerHour: ptr.To(10), + MaxConnectionsPerHour: ptr.To(10), + MaxUserConnections: ptr.To(10), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + ResourceOptionsAsClauses: []string{ + "MAX_QUERIES_PER_HOUR 20", // default ResourceOptions values + "MAX_UPDATES_PER_HOUR 20", + "MAX_CONNECTIONS_PER_HOUR 20", + "MAX_USER_CONNECTIONS 20", + }, + AuthPlugin: ptr.To(""), // default AuthPlugin value + }, + }, + }, + kube: &test.MockClient{ + MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + secret := corev1.Secret{ + Data: map[string][]byte{}, + } + secret.Data[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("samesame") + secret.DeepCopyInto(obj.(*corev1.Secret)) + return nil + }, + }, + }, + want: want{ + err: nil, + }, + }, "NoUpdateQueryUnchangedResourceOptions": { reason: "We should not execute an SQL query if the resource options are unchanged.", fields: fields{ @@ -731,11 +849,92 @@ func TestUpdate(t *testing.T) { Status: v1alpha1.UserStatus{ AtProvider: v1alpha1.UserObservation{ ResourceOptionsAsClauses: []string{ - "MAX_QUERIES_PER_HOUR 0", + "MAX_QUERIES_PER_HOUR 0", // default ResourceOptions values "MAX_UPDATES_PER_HOUR 0", "MAX_CONNECTIONS_PER_HOUR 0", "MAX_USER_CONNECTIONS 0", }, + AuthPlugin: ptr.To(""), // default AuthPlugin value + }, + }, + }, + kube: &test.MockClient{ + MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + secret := corev1.Secret{ + Data: map[string][]byte{}, + } + secret.Data[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("samesame") + secret.DeepCopyInto(obj.(*corev1.Secret)) + return nil + }, + }, + }, + want: want{ + err: nil, + }, + }, + "UserWithAnAuthPluginThatNotRequiresPassword": { + reason: "A user that uses an authentication plugin that does not require password should not receive connection details and no error should happen", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + UsePassword: ptr.To(false), + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalUpdate{}, + }, + }, + "UpdatedAuthPlugin": { + reason: "We should execute an SQL query if the auth plugin is not synced.", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + PasswordSecretRef: &xpv1.SecretKeySelector{ + SecretReference: xpv1.SecretReference{ + Name: "connection-secret", + }, + Key: xpv1.ResourceCredentialsSecretPasswordKey, + }, + AuthPlugin: ptr.To(""), + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To("authentication_ldap_simple"), }, }, }, @@ -752,6 +951,7 @@ func TestUpdate(t *testing.T) { }, want: want{ err: nil, + c: managed.ExternalUpdate{}, }, }, } diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 7485c077..15dccda9 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -176,6 +176,7 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) observed := &namespacedv1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &namespacedv1alpha1.ResourceOptions{}, } @@ -183,7 +184,8 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -197,6 +199,7 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + observed.AuthPlugin, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -211,6 +214,7 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthPlugin = observed.AuthPlugin mg.SetConditions(xpv1.Available()) @@ -224,20 +228,25 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - pw, _, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalCreation{}, err - } + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - if pw == "" { - pw, err = password.Generate() + var pw *string + if checkUsePassword(mg) { + userPassword, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err } + + if userPassword == "" { + userPassword, err = password.Generate() + if err != nil { + return managed.ExternalCreation{}, err + } + } + pw = &userPassword } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { + if err := c.executeCreateUserQuery(ctx, username, host, mg.Spec.ForProvider.AuthPlugin, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -245,30 +254,33 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man mg.Status.AtProvider.ResourceOptionsAsClauses = ro } - return managed.ExternalCreation{ - ConnectionDetails: c.db.GetConnectionDetails(username, pw), - }, nil + if pw != nil { + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, *pw), + }, nil + } + + return managed.ExternalCreation{}, nil } -func (c *external) executeCreateUserQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, pw string) error { +func (c *external) executeCreateUserQuery(ctx context.Context, username string, host string, authPlugin *string, resourceOptionsClauses []string, pw *string) error { + plugin := defaultAuthPlugin(authPlugin) + identifiedClause := buildIdentifiedClause(plugin, pw) + resourceOptions := "" if len(resourceOptionsClauses) != 0 { resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) } query := fmt.Sprintf( - "CREATE USER %s@%s IDENTIFIED BY %s%s", + "CREATE USER %s@%s %s%s", mysql.QuoteValue(username), mysql.QuoteValue(host), - mysql.QuoteValue(pw), + identifiedClause, resourceOptions, ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { - return err - } - - return nil + return mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}) } func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalUpdate, error) { @@ -301,49 +313,114 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalUpdate{}, err } - if len(connectionDetails) > 0 { - return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil + return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil +} + +func (c *external) Disconnect(ctx context.Context) error { + return nil +} + +func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalDelete, error) { + mg.SetConditions(xpv1.Deleting()) + + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + + query := fmt.Sprintf("DROP USER IF EXISTS %s@%s", mysql.QuoteValue(username), mysql.QuoteValue(host)) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errDropUser}); err != nil { + return managed.ExternalDelete{}, err + } + + return managed.ExternalDelete{}, nil +} + +func checkUsePassword(mg *namespacedv1alpha1.User) bool { + if mg.Spec.ForProvider.UsePassword == nil { + return true } - return managed.ExternalUpdate{}, nil + return *mg.Spec.ForProvider.UsePassword } -func (c *external) UpdatePassword(ctx context.Context, cr *namespacedv1alpha1.User, username, host string) (managed.ConnectionDetails, error) { - pw, pwchanged, err := c.getPassword(ctx, cr) +// defaultAuthPlugin returns the authentication plugin to use. +// If the input is nil or an empty string, it returns "" to indicate using the server default. +// Otherwise, it returns the specified plugin name. +func defaultAuthPlugin(plugin *string) string { + if plugin == nil || *plugin == "" { + return "" + } + return *plugin +} + +// UpdatePassword updates the password and/or auth plugin for a user if either has changed +func (c *external) UpdatePassword(ctx context.Context, mg *namespacedv1alpha1.User, username string, host string) (managed.ConnectionDetails, error) { + pw, pwChanged, err := c.getPassword(ctx, mg) if err != nil { - return managed.ConnectionDetails{}, err + return nil, err } - if pwchanged { - query := fmt.Sprintf("ALTER USER %s@%s IDENTIFIED BY %s", mysql.QuoteValue(username), mysql.QuoteValue(host), mysql.QuoteValue(pw)) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { - return managed.ConnectionDetails{}, err - } + pluginChanged := false + desiredPlugin := defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + if mg.Status.AtProvider.AuthPlugin != nil { + observedPlugin := defaultAuthPlugin(mg.Status.AtProvider.AuthPlugin) + pluginChanged = desiredPlugin != observedPlugin + } + if !pwChanged && !pluginChanged { + return nil, nil + } + + if err := c.executeAlterUserQuery(ctx, username, host, desiredPlugin, pw); err != nil { + return nil, err + } + + if pwChanged { return c.db.GetConnectionDetails(username, pw), nil } - return managed.ConnectionDetails{}, nil + return nil, nil } -func (c *external) Disconnect(ctx context.Context) error { - return nil -} +func (c *external) executeAlterUserQuery(ctx context.Context, username string, host string, plugin string, pw string) error { + identifiedClause := buildIdentifiedClause(plugin, &pw) + if identifiedClause == "" { + // No password and no plugin means nothing to update + return nil + } -func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalDelete, error) { - mg.SetConditions(xpv1.Deleting()) + query := fmt.Sprintf("ALTER USER %s@%s %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + identifiedClause, + ) - username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + return mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}) +} - query := fmt.Sprintf("DROP USER IF EXISTS %s@%s", mysql.QuoteValue(username), mysql.QuoteValue(host)) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errDropUser}); err != nil { - return managed.ExternalDelete{}, err +// buildIdentifiedClause constructs the IDENTIFIED clause for CREATE/ALTER USER statements. +func buildIdentifiedClause(plugin string, pw *string) string { + if plugin == "" { + if pw != nil && *pw != "" { + return fmt.Sprintf("IDENTIFIED BY %s", mysql.QuoteValue(*pw)) + } + return "" } - return managed.ExternalDelete{}, nil + identifiedClause := fmt.Sprintf("IDENTIFIED WITH %s", plugin) + if pw != nil && *pw != "" { + identifiedClause += fmt.Sprintf(" BY %s", mysql.QuoteValue(*pw)) + } + return identifiedClause } func upToDate(observed *namespacedv1alpha1.UserParameters, desired *namespacedv1alpha1.UserParameters) bool { + // Check auth plugin + observedPlugin := defaultAuthPlugin(observed.AuthPlugin) + desiredPlugin := defaultAuthPlugin(desired.AuthPlugin) + if observedPlugin != desiredPlugin { + return false + } + + // Check resource options if desired.ResourceOptions == nil { // Return true if there are no desired ResourceOptions return true diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index 3946ac07..b058d9b2 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -28,6 +28,7 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/apis/common" @@ -291,7 +292,15 @@ func TestObserve(t *testing.T) { reason: "We should return no error if we can successfully select our user", fields: fields{ db: mockDB{ - MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { return nil }, + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + // Set authPlugin to empty string (default) + if len(dest) >= 5 { + if pluginPtr, ok := dest[4].(*string); ok { + *pluginPtr = "" + } + } + return nil + }, }, }, args: args{ @@ -313,7 +322,15 @@ func TestObserve(t *testing.T) { reason: "We should return ResourceUpToDate=false if the password changed", fields: fields{ db: mockDB{ - MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { return nil }, + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + // Set authPlugin to empty string (default) + if len(dest) >= 5 { + if pluginPtr, ok := dest[4].(*string); ok { + *pluginPtr = "" + } + } + return nil + }, }, kube: &test.MockClient{ MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { @@ -530,6 +547,34 @@ func TestCreate(t *testing.T) { }, }, }, + "UserWithAnAuthPluginThatNotRequiresPassword": { + reason: "A user that uses an authentication plugin that does not require password should not receive connection details and no error should happen", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + UsePassword: ptr.To(false), + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{}, + }, + }, } for name, tc := range cases { @@ -666,6 +711,11 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, kube: &test.MockClient{ MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { @@ -789,6 +839,7 @@ func TestUpdate(t *testing.T) { "MAX_CONNECTIONS_PER_HOUR 0", "MAX_USER_CONNECTIONS 0", }, + AuthPlugin: ptr.To(""), }, }, }, @@ -807,6 +858,87 @@ func TestUpdate(t *testing.T) { err: nil, }, }, + "UserWithAnAuthPluginThatNotRequiresPassword": { + reason: "A user that uses an authentication plugin that does not require password should not receive connection details and no error should happen", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { return nil }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + UsePassword: ptr.To(false), + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalUpdate{}, + }, + }, + "UpdatedAuthPlugin": { + reason: "We should execute an SQL query if the auth plugin is not synced.", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + PasswordSecretRef: &common.LocalSecretKeySelector{ + LocalSecretReference: common.LocalSecretReference{ + Name: "connection-secret", + }, + Key: xpv1.ResourceCredentialsSecretPasswordKey, + }, + AuthPlugin: ptr.To(""), + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To("authentication_ldap_simple"), + }, + }, + }, + kube: &test.MockClient{ + MockGet: func(_ context.Context, key client.ObjectKey, obj client.Object) error { + secret := corev1.Secret{ + Data: map[string][]byte{}, + } + secret.Data[xpv1.ResourceCredentialsSecretPasswordKey] = []byte("samesame") + secret.DeepCopyInto(obj.(*corev1.Secret)) + return nil + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalUpdate{}, + }, + }, } for name, tc := range cases {