diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index c59d4bae..931d4b5b 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-z0-9_]+$ + 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 fff8a6cc..cb1ce22e 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -662,6 +662,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. @@ -687,6 +692,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..14ae63de 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-z0-9_]+$ + 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 fae51bbd..6ec4ae6d 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -775,6 +775,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)) @@ -800,6 +805,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 fa1c6574..1a88ae54 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-z0-9_]+$ + 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 75edcbdf..433edf48 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-z0-9_]+$ + 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 3eba36a6..9102abdd 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -57,6 +57,7 @@ const ( errUpdateUser = "cannot update user" errGetPasswordSecretFailed = "cannot get password secret" errCompareResourceOptions = "cannot compare desired and observed resource options" + errMissingAuthPlugin = "usePassword=false requires a non-empty authPlugin" maxConcurrency = 5 ) @@ -198,9 +199,14 @@ func changedResourceOptions(existing []string, desired []string) ([]string, erro } func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.ExternalObservation, error) { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { + return managed.ExternalObservation{}, err + } + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) observed := &v1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &v1alpha1.ResourceOptions{}, } @@ -208,7 +214,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{ @@ -222,6 +229,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 @@ -230,12 +238,16 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } - _, pwdChanged, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalObservation{}, err + pwdChanged := false + if checkUsePassword(mg) { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthPlugin = observed.AuthPlugin mg.SetConditions(xpv1.Available()) @@ -248,21 +260,31 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) - username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - pw, _, err := c.getPassword(ctx, mg) - if err != nil { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { return managed.ExternalCreation{}, err } - if pw == "" { - pw, err = password.Generate() + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + plugin := defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + 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 } @@ -270,33 +292,39 @@ 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) { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { + return managed.ExternalUpdate{}, err + } + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) @@ -326,29 +354,106 @@ 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 := false + if checkUsePassword(mg) { + var err error + pw, pwChanged, err = c.getPassword(ctx, mg) + if err != nil { + return nil, err + } + } + + pluginChanged := false + desiredPlugin := "" + if authPluginManaged(mg.Spec.ForProvider.AuthPlugin) { + desiredPlugin = defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + 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 +} - return managed.ConnectionDetails{}, nil +func checkUsePassword(mg *v1alpha1.User) bool { + if mg.Spec.ForProvider.UsePassword == nil { + return true + } + + return *mg.Spec.ForProvider.UsePassword +} + +func authPluginManaged(authPlugin *string) bool { + return authPlugin != nil && *authPlugin != "" +} + +func validateAuthConfig(authPlugin *string, usePassword bool) error { + if !usePassword && !authPluginManaged(authPlugin) { + return errors.New(errMissingAuthPlugin) + } + + return nil +} + +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 { @@ -369,6 +474,16 @@ func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.Exter } func upToDate(observed *v1alpha1.UserParameters, desired *v1alpha1.UserParameters) bool { + // Check auth plugin + if authPluginManaged(desired.AuthPlugin) { + 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 8d88abd0..27ddae4a 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" @@ -242,7 +243,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{ @@ -304,6 +315,36 @@ func TestObserve(t *testing.T) { err: nil, }, }, + "OmittedAuthPluginDoesNotForceReconciliation": { + reason: "We should not compare auth plugins when authPlugin is omitted from the spec", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + if len(dest) >= 5 { + if plugin, ok := dest[4].(**string); ok { + observedPlugin := "authentication_ldap_simple" + *plugin = &observedPlugin + } + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{}, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, } for name, tc := range cases { @@ -481,6 +522,52 @@ 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{}, + }, + }, + "ErrMissingAuthPluginWhenPasswordDisabled": { + reason: "We should reject disabling passwords without an explicit auth plugin", + fields: fields{ + db: &mockDB{}, + }, + args: args{ + mg: &v1alpha1.User{ + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + UsePassword: ptr.To(false), + }, + }, + }, + }, + want: want{ + err: errors.New(errMissingAuthPlugin), + }, + }, } for name, tc := range cases { @@ -558,6 +645,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 { @@ -588,6 +680,11 @@ func TestUpdate(t *testing.T) { meta.AnnotationKeyExternalName: "example", }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthPlugin: ptr.To(""), + }, + }, }, }, want: want{ @@ -617,6 +714,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 { @@ -660,6 +762,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 { @@ -696,6 +803,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{ @@ -735,11 +901,12 @@ 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 }, }, }, @@ -758,6 +925,128 @@ 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: 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{}, + }, + }, + "SkipPasswordLookupWhenPasswordDisabled": { + reason: "We should not read password secrets when usePassword is false", + fields: fields{ + db: &mockDB{}, + }, + 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), + PasswordSecretRef: &xpv1.SecretKeySelector{ + SecretReference: xpv1.SecretReference{ + Name: "should-not-be-read", + }, + Key: xpv1.ResourceCredentialsSecretPasswordKey, + }, + }, + }, + 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 { + return errBoom + }, + }, + }, + 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"), + }, + }, + }, + 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 { diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index de6f2189..3a089401 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -53,6 +53,7 @@ const ( errUpdateUser = "cannot update user" errGetPasswordSecretFailed = "cannot get password secret" errCompareResourceOptions = "cannot compare desired and observed resource options" + errMissingAuthPlugin = "usePassword=false requires a non-empty authPlugin" maxConcurrency = 5 ) @@ -180,9 +181,14 @@ func changedResourceOptions(existing []string, desired []string) ([]string, erro } func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalObservation, error) { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { + return managed.ExternalObservation{}, err + } + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) observed := &namespacedv1alpha1.UserParameters{ + AuthPlugin: new(string), ResourceOptions: &namespacedv1alpha1.ResourceOptions{}, } @@ -190,7 +196,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{ @@ -204,6 +211,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 @@ -212,12 +220,16 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } - _, pwdChanged, err := c.getPassword(ctx, mg) - if err != nil { - return managed.ExternalObservation{}, err + pwdChanged := false + if checkUsePassword(mg) { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthPlugin = observed.AuthPlugin mg.SetConditions(xpv1.Available()) @@ -230,21 +242,30 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) - username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - pw, _, err := c.getPassword(ctx, mg) - if err != nil { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { return managed.ExternalCreation{}, err } - if pw == "" { - pw, err = password.Generate() + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + 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 } @@ -252,33 +273,40 @@ 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) { + if err := validateAuthConfig(mg.Spec.ForProvider.AuthPlugin, checkUsePassword(mg)); err != nil { + return managed.ExternalUpdate{}, err + } + username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) @@ -308,49 +336,134 @@ 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.ExternalUpdate{}, nil + return managed.ExternalDelete{}, 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 checkUsePassword(mg *namespacedv1alpha1.User) bool { + if mg.Spec.ForProvider.UsePassword == nil { + return true } - 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 + return *mg.Spec.ForProvider.UsePassword +} + +func authPluginManaged(authPlugin *string) bool { + return authPlugin != nil && *authPlugin != "" +} + +func validateAuthConfig(authPlugin *string, usePassword bool) error { + if !usePassword && !authPluginManaged(authPlugin) { + return errors.New(errMissingAuthPlugin) + } + + return nil +} + +// 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 := false + if checkUsePassword(mg) { + var err error + pw, pwChanged, err = c.getPassword(ctx, mg) + if err != nil { + return nil, err } + } + + pluginChanged := false + desiredPlugin := "" + if authPluginManaged(mg.Spec.ForProvider.AuthPlugin) { + desiredPlugin = defaultAuthPlugin(mg.Spec.ForProvider.AuthPlugin) + 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 + if authPluginManaged(desired.AuthPlugin) { + 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 23e9cb56..284fd1b3 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" @@ -295,7 +296,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{ @@ -317,7 +326,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 { @@ -357,6 +374,35 @@ func TestObserve(t *testing.T) { err: nil, }, }, + "OmittedAuthPluginDoesNotForceReconciliation": { + reason: "We should not compare auth plugins when authPlugin is omitted from the spec", + fields: fields{ + db: mockDB{ + MockScan: func(ctx context.Context, q xsql.Query, dest ...interface{}) error { + if len(dest) >= 5 { + if pluginPtr, ok := dest[4].(*string); ok { + *pluginPtr = "authentication_ldap_simple" + } + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{}, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + }, + err: nil, + }, + }, } for name, tc := range cases { @@ -534,6 +580,52 @@ 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{}, + }, + }, + "ErrMissingAuthPluginWhenPasswordDisabled": { + reason: "We should reject disabling passwords without an explicit auth plugin", + fields: fields{ + db: &mockDB{}, + }, + args: args{ + mg: &v1alpha1.User{ + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + UsePassword: ptr.To(false), + }, + }, + }, + }, + want: want{ + err: errors.New(errMissingAuthPlugin), + }, + }, } for name, tc := range cases { @@ -670,6 +762,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 { @@ -793,6 +890,128 @@ func TestUpdate(t *testing.T) { "MAX_CONNECTIONS_PER_HOUR 0", "MAX_USER_CONNECTIONS 0", }, + AuthPlugin: ptr.To(""), + }, + }, + }, + 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: 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{}, + }, + }, + "SkipPasswordLookupWhenPasswordDisabled": { + reason: "We should not read password secrets when usePassword is false", + fields: fields{ + db: &mockDB{}, + }, + 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), + PasswordSecretRef: &common.LocalSecretKeySelector{ + LocalSecretReference: common.LocalSecretReference{ + Name: "should-not-be-read", + }, + Key: xpv1.ResourceCredentialsSecretPasswordKey, + }, + }, + }, + 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 { + return errBoom + }, + }, + }, + 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"), }, }, }, @@ -809,6 +1028,7 @@ func TestUpdate(t *testing.T) { }, want: want{ err: nil, + c: managed.ExternalUpdate{}, }, }, }