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 542e5a78..b290af09 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 c3ffdd87..8d2be10a 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -218,6 +218,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) observed := &v1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &v1alpha1.ResourceOptions{}, } @@ -225,7 +226,8 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex "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{ @@ -239,6 +241,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &observed.AuthPlugin, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -253,6 +256,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex } cr.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + cr.Status.AtProvider.AuthPlugin = observed.AuthPlugin cr.SetConditions(xpv1.Available()) @@ -271,20 +275,26 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext cr.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) - pw, _, err := c.getPassword(ctx, cr) - if err != nil { - return managed.ExternalCreation{}, err - } + plugin := defaultAuthPlugin(cr.Spec.ForProvider.AuthPlugin) + ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) - if pw == "" { - pw, err = password.Generate() + var pw *string + if checkUsePassword(cr) { + userPassword, _, err := c.getPassword(ctx, cr) if err != nil { return managed.ExternalCreation{}, err } + + if userPassword == "" { + userPassword, err = password.Generate() + if err != nil { + return managed.ExternalCreation{}, err + } + } + pw = &userPassword } - ro := resourceOptionsToClauses(cr.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 } @@ -292,30 +302,32 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext cr.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 resource.Managed) (managed.ExternalUpdate, error) { @@ -325,57 +337,155 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext } username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) + plugin := defaultAuthPlugin(cr.Spec.ForProvider.AuthPlugin) - ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(cr.Status.AtProvider.ResourceOptionsAsClauses, ro) + roToAlter, err := getResourceOptionsToAlter(cr) if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + return managed.ExternalUpdate{}, err } - if len(rochanged) > 0 { - resourceOptions := fmt.Sprintf("WITH %s", strings.Join(ro, " ")) - - query := fmt.Sprintf( - "ALTER USER %s@%s %s", - mysql.QuoteValue(username), - mysql.QuoteValue(host), - resourceOptions, - ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + password := "" + passwordChanged := false + if checkUsePassword(cr) { + password, passwordChanged, err = c.getPassword(ctx, cr) + if err != nil { return managed.ExternalUpdate{}, err } + } - cr.Status.AtProvider.ResourceOptionsAsClauses = ro + return c.applyUserChanges(ctx, cr, passwordChanged, roToAlter, username, host, plugin, password) +} + +func (c *external) applyUserChanges(ctx context.Context, cr *v1alpha1.User, passwordChanged bool, roToAlter []string, username string, host string, plugin string, password string) (managed.ExternalUpdate, error) { + // Handle resource options changes (separate ALTER USER statement) + if err := c.updateResourceOptionsIfChanged(ctx, cr, roToAlter, username, host); err != nil { + return managed.ExternalUpdate{}, err } - connectionDetails, err := c.UpdatePassword(ctx, cr, username, host) - if err != nil { + // Handle auth plugin and/or password changes (separate ALTER USER statement) + return c.updateAuthIfChanged(ctx, cr, passwordChanged, username, host, plugin, password) +} + +func (c *external) updateResourceOptionsIfChanged(ctx context.Context, cr *v1alpha1.User, roToAlter []string, username string, host string) error { + if !checkResourceOptionsChanged(roToAlter) { + return nil + } + + resourceOptions := fmt.Sprintf(" WITH %s", strings.Join(roToAlter, " ")) + query := fmt.Sprintf( + "ALTER USER %s@%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + resourceOptions, + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + cr.Status.AtProvider.ResourceOptionsAsClauses = roToAlter + return nil +} + +func (c *external) updateAuthIfChanged(ctx context.Context, cr *v1alpha1.User, passwordChanged bool, username string, host string, plugin string, password string) (managed.ExternalUpdate, error) { + needsPasswordUpdate := checkUsePassword(cr) && passwordChanged + needsAuthPluginUpdate := checkAuthPluginChanged(cr) + + if !needsPasswordUpdate && !needsAuthPluginUpdate { + return managed.ExternalUpdate{}, nil + } + + if err := c.executeAlterUserQuery(ctx, username, host, plugin, password); err != nil { return managed.ExternalUpdate{}, err } - if len(connectionDetails) > 0 { - return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil + // Return connection details if password was updated + if needsPasswordUpdate { + return managed.ExternalUpdate{ConnectionDetails: c.db.GetConnectionDetails(username, password)}, nil } return managed.ExternalUpdate{}, 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 +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 "" + } + + identifiedClause := fmt.Sprintf("IDENTIFIED WITH %s", plugin) + if pw != nil && *pw != "" { + identifiedClause += fmt.Sprintf(" BY %s", mysql.QuoteValue(*pw)) + } + return identifiedClause +} + +func getResourceOptionsToAlter(cr *v1alpha1.User) ([]string, error) { + roToAlter := []string{} + + ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) + roChanged, err := changedResourceOptions(cr.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return roToAlter, errors.Wrap(err, errUpdateUser) + } + + if len(roChanged) > 0 { + roToAlter = ro + } - return c.db.GetConnectionDetails(username, pw), nil + return roToAlter, nil +} + +func checkUsePassword(cr *v1alpha1.User) bool { + if cr.Spec.ForProvider.UsePassword == nil { + return true + } + + return *cr.Spec.ForProvider.UsePassword +} + +func checkResourceOptionsChanged(roToAlter []string) bool { + return len(roToAlter) > 0 +} + +func checkAuthPluginChanged(cr *v1alpha1.User) bool { + if cr.Status.AtProvider.AuthPlugin == nil { + return true } - return managed.ConnectionDetails{}, nil + if *cr.Status.AtProvider.AuthPlugin != defaultAuthPlugin(cr.Spec.ForProvider.AuthPlugin) { + return true + } + + return false +} + +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 { @@ -401,6 +511,14 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) (managed.Ext } 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 23420075..ade61a63 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" @@ -253,7 +254,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{ @@ -501,6 +512,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 { @@ -587,6 +626,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 { @@ -617,6 +661,11 @@ func TestUpdate(t *testing.T) { meta.AnnotationKeyExternalName: "example", }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, }, want: want{ @@ -646,6 +695,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 { @@ -689,6 +743,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 { @@ -725,6 +784,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{ @@ -764,11 +882,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"), }, }, }, @@ -785,6 +984,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 843011ec..d0a8752b 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -199,6 +199,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) observed := &namespacedv1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &namespacedv1alpha1.ResourceOptions{}, } @@ -206,7 +207,8 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex "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{ @@ -220,6 +222,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + observed.AuthPlugin, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -234,6 +237,7 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex } cr.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + cr.Status.AtProvider.AuthPlugin = observed.AuthPlugin cr.SetConditions(xpv1.Available()) @@ -252,20 +256,25 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext cr.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) - pw, _, err := c.getPassword(ctx, cr) - if err != nil { - return managed.ExternalCreation{}, err - } + ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) - if pw == "" { - pw, err = password.Generate() + var pw *string + if checkUsePassword(cr) { + userPassword, _, err := c.getPassword(ctx, cr) if err != nil { return managed.ExternalCreation{}, err } + + if userPassword == "" { + userPassword, err = password.Generate() + if err != nil { + return managed.ExternalCreation{}, err + } + } + pw = &userPassword } - ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) - if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { + if err := c.executeCreateUserQuery(ctx, username, host, cr.Spec.ForProvider.AuthPlugin, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -273,30 +282,33 @@ func (c *external) Create(ctx context.Context, mg resource.Managed) (managed.Ext cr.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 resource.Managed) (managed.ExternalUpdate, error) { @@ -306,57 +318,72 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext } username, host := mysql.SplitUserHost(meta.GetExternalName(cr)) + plugin := defaultAuthPlugin(cr.Spec.ForProvider.AuthPlugin) - ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(cr.Status.AtProvider.ResourceOptionsAsClauses, ro) + roToAlter, err := getResourceOptionsToAlter(cr) if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + return managed.ExternalUpdate{}, err } - if len(rochanged) > 0 { - resourceOptions := fmt.Sprintf("WITH %s", strings.Join(ro, " ")) - - query := fmt.Sprintf( - "ALTER USER %s@%s %s", - mysql.QuoteValue(username), - mysql.QuoteValue(host), - resourceOptions, - ) - if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + password := "" + passwordChanged := false + if checkUsePassword(cr) { + password, passwordChanged, err = c.getPassword(ctx, cr) + if err != nil { return managed.ExternalUpdate{}, err } - - cr.Status.AtProvider.ResourceOptionsAsClauses = ro } - connectionDetails, err := c.UpdatePassword(ctx, cr, username, host) - if err != nil { + return c.applyUserChanges(ctx, cr, passwordChanged, roToAlter, username, host, plugin, password) +} + +func (c *external) applyUserChanges(ctx context.Context, cr *namespacedv1alpha1.User, passwordChanged bool, roToAlter []string, username string, host string, plugin string, password string) (managed.ExternalUpdate, error) { + // Handle resource options changes (separate ALTER USER statement) + if err := c.updateResourceOptionsIfChanged(ctx, cr, roToAlter, username, host); err != nil { return managed.ExternalUpdate{}, err } - if len(connectionDetails) > 0 { - return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil + // Handle auth plugin and/or password changes (separate ALTER USER statement) + return c.updateAuthIfChanged(ctx, cr, passwordChanged, username, host, plugin, password) +} + +func (c *external) updateResourceOptionsIfChanged(ctx context.Context, cr *namespacedv1alpha1.User, roToAlter []string, username string, host string) error { + if !checkResourceOptionsChanged(roToAlter) { + return nil } - return managed.ExternalUpdate{}, nil + resourceOptions := fmt.Sprintf(" WITH %s", strings.Join(roToAlter, " ")) + query := fmt.Sprintf( + "ALTER USER %s@%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + resourceOptions, + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + cr.Status.AtProvider.ResourceOptionsAsClauses = roToAlter + return nil } -func (c *external) UpdatePassword(ctx context.Context, cr *namespacedv1alpha1.User, username, host string) (managed.ConnectionDetails, error) { - pw, pwchanged, err := c.getPassword(ctx, cr) - if err != nil { - return managed.ConnectionDetails{}, err +func (c *external) updateAuthIfChanged(ctx context.Context, cr *namespacedv1alpha1.User, passwordChanged bool, username string, host string, plugin string, password string) (managed.ExternalUpdate, error) { + needsPasswordUpdate := checkUsePassword(cr) && passwordChanged + needsAuthPluginUpdate := checkAuthPluginChanged(cr) + + if !needsPasswordUpdate && !needsAuthPluginUpdate { + return managed.ExternalUpdate{}, 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 - } + if err := c.executeAlterUserQuery(ctx, username, host, plugin, password); err != nil { + return managed.ExternalUpdate{}, err + } - return c.db.GetConnectionDetails(username, pw), nil + // Return connection details if password was updated + if needsPasswordUpdate { + return managed.ExternalUpdate{ConnectionDetails: c.db.GetConnectionDetails(username, password)}, nil } - return managed.ConnectionDetails{}, nil + return managed.ExternalUpdate{}, nil } func (c *external) Disconnect(ctx context.Context) error { @@ -381,7 +408,97 @@ func (c *external) Delete(ctx context.Context, mg resource.Managed) (managed.Ext return managed.ExternalDelete{}, nil } +func checkUsePassword(cr *namespacedv1alpha1.User) bool { + if cr.Spec.ForProvider.UsePassword == nil { + return true + } + + return *cr.Spec.ForProvider.UsePassword +} + +// 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 +} + +func checkAuthPluginChanged(cr *namespacedv1alpha1.User) bool { + if cr.Status.AtProvider.AuthPlugin == nil { + return true + } + + if *cr.Status.AtProvider.AuthPlugin != defaultAuthPlugin(cr.Spec.ForProvider.AuthPlugin) { + return true + } + + return false +} + +func getResourceOptionsToAlter(cr *namespacedv1alpha1.User) ([]string, error) { + roToAlter := []string{} + + ro := resourceOptionsToClauses(cr.Spec.ForProvider.ResourceOptions) + roChanged, err := changedResourceOptions(cr.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return roToAlter, errors.Wrap(err, errUpdateUser) + } + + if len(roChanged) > 0 { + roToAlter = ro + } + + return roToAlter, nil +} + +func checkResourceOptionsChanged(roToAlter []string) bool { + return len(roToAlter) > 0 +} + +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 + } + + 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 "" + } + + 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 71630b02..369bd5ce 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" @@ -307,7 +308,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{ @@ -329,7 +338,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 { @@ -555,6 +572,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 { @@ -700,6 +745,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 { @@ -823,6 +873,7 @@ func TestUpdate(t *testing.T) { "MAX_CONNECTIONS_PER_HOUR 0", "MAX_USER_CONNECTIONS 0", }, + AuthPlugin: ptr.To(""), }, }, }, @@ -841,6 +892,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 {