From 2f1b7677f71a7bcc8418de9d6bdf161a0f8e77ae Mon Sep 17 00:00:00 2001 From: Ron Levy Date: Thu, 30 Apr 2026 12:53:13 +0300 Subject: [PATCH 1/4] feat(mysql/user): support non-default authentication plugins for IAM auth Add an optional AuthenticationPlugin field on the MySQL User resource. When set, the user is created with CREATE USER ... IDENTIFIED WITH [AS ''] instead of IDENTIFIED BY . No password is generated, the connection secret omits the password key, and ALTER USER ... IDENTIFIED BY is skipped on Update so the provider does not downgrade plugin-auth users to native password auth. Primary motivation is AWS RDS IAM authentication, where the DB user has no static password and clients fetch a short-lived auth token from AWS at connect time: spec: forProvider: authenticationPlugin: name: AWSAuthenticationPlugin authString: RDS The same field also works for other auth plugins (auth_socket, caching_sha2_password, etc.). Backward compatible: when AuthenticationPlugin is unset the existing password-based flow is preserved. Applied to both the cluster and namespaced API variants. Plugin name is quoted as a SQL identifier; auth string is quoted as a SQL value. Signed-off-by: Ron Levy --- apis/cluster/mysql/v1alpha1/user_types.go | 35 ++++- .../mysql/v1alpha1/zz_generated.deepcopy.go | 25 ++++ apis/namespaced/mysql/v1alpha1/user_types.go | 35 ++++- .../mysql/v1alpha1/zz_generated.deepcopy.go | 25 ++++ examples/cluster/mysql/user_iam_auth.yaml | 22 +++ examples/namespaced/mysql/user_iam_auth.yaml | 22 +++ .../crds/mysql.sql.crossplane.io_users.yaml | 36 ++++- .../crds/mysql.sql.m.crossplane.io_users.yaml | 36 ++++- .../cluster/mysql/user/reconciler.go | 69 +++++++++- .../cluster/mysql/user/reconciler_test.go | 128 ++++++++++++++++++ .../namespaced/mysql/user/reconciler.go | 68 +++++++++- .../namespaced/mysql/user/reconciler_test.go | 128 ++++++++++++++++++ 12 files changed, 617 insertions(+), 12 deletions(-) create mode 100644 examples/cluster/mysql/user_iam_auth.yaml create mode 100644 examples/namespaced/mysql/user_iam_auth.yaml diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index c59d4bae..b43cc406 100644 --- a/apis/cluster/mysql/v1alpha1/user_types.go +++ b/apis/cluster/mysql/v1alpha1/user_types.go @@ -37,10 +37,29 @@ type UserStatus struct { // UserParameters define the desired state of a MySQL user instance. type UserParameters struct { // PasswordSecretRef references the secret that contains the password used - // for this user. If no reference is given, a password will be auto-generated. + // for this user. If no reference is given and AuthenticationPlugin is + // unset, a password will be auto-generated. + // Ignored when AuthenticationPlugin is set. // +optional PasswordSecretRef *xpv1.SecretKeySelector `json:"passwordSecretRef,omitempty"` + // AuthenticationPlugin selects a non-default MySQL authentication plugin + // for the user. When set, the user is created with + // CREATE USER ... IDENTIFIED WITH [AS ''] and no + // password is generated or required. + // + // Most commonly used for AWS RDS IAM authentication, where the DB user + // has no static password and clients authenticate via short-lived IAM + // auth tokens. Example: + // authenticationPlugin: + // name: AWSAuthenticationPlugin + // authString: RDS + // + // When AuthenticationPlugin is set, PasswordSecretRef is ignored and + // the connection secret will not contain a password. + // +optional + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` + // ResourceOptions sets account specific resource limits. // See https://dev.mysql.com/doc/refman/8.0/en/user-resources.html // +optional @@ -51,6 +70,20 @@ type UserParameters struct { BinLog *bool `json:"binlog,omitempty"` } +// AuthenticationPlugin selects the auth plugin used when creating the user. +type AuthenticationPlugin struct { + // Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + // "mysql_native_password", "caching_sha2_password", "auth_socket". + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // AuthString is the optional auth string passed to the plugin via the + // AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + // password plugins, leave empty and use PasswordSecretRef instead. + // +optional + AuthString *string `json:"authString,omitempty"` +} + // ResourceOptions define the account specific resource limits. type ResourceOptions struct { // MaxQueriesPerHour sets the number of queries an account can issue per hour diff --git a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go index 81e186c0..8df78fd2 100644 --- a/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mysql/v1alpha1/zz_generated.deepcopy.go @@ -13,6 +13,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthenticationPlugin) DeepCopyInto(out *AuthenticationPlugin) { + *out = *in + if in.AuthString != nil { + in, out := &in.AuthString, &out.AuthString + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationPlugin. +func (in *AuthenticationPlugin) DeepCopy() *AuthenticationPlugin { + if in == nil { + return nil + } + out := new(AuthenticationPlugin) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Database) DeepCopyInto(out *Database) { *out = *in @@ -662,6 +682,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.SecretKeySelector) **out = **in } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } if in.ResourceOptions != nil { in, out := &in.ResourceOptions, &out.ResourceOptions *out = new(ResourceOptions) diff --git a/apis/namespaced/mysql/v1alpha1/user_types.go b/apis/namespaced/mysql/v1alpha1/user_types.go index d8a8a1c8..4bff8c2b 100644 --- a/apis/namespaced/mysql/v1alpha1/user_types.go +++ b/apis/namespaced/mysql/v1alpha1/user_types.go @@ -38,10 +38,29 @@ type UserStatus struct { // UserParameters define the desired state of a MySQL user instance. type UserParameters struct { // PasswordSecretRef references the secret that contains the password used - // for this user. If no reference is given, a password will be auto-generated. + // for this user. If no reference is given and AuthenticationPlugin is + // unset, a password will be auto-generated. + // Ignored when AuthenticationPlugin is set. // +optional PasswordSecretRef *xpv1.LocalSecretKeySelector `json:"passwordSecretRef,omitempty"` + // AuthenticationPlugin selects a non-default MySQL authentication plugin + // for the user. When set, the user is created with + // CREATE USER ... IDENTIFIED WITH [AS ''] and no + // password is generated or required. + // + // Most commonly used for AWS RDS IAM authentication, where the DB user + // has no static password and clients authenticate via short-lived IAM + // auth tokens. Example: + // authenticationPlugin: + // name: AWSAuthenticationPlugin + // authString: RDS + // + // When AuthenticationPlugin is set, PasswordSecretRef is ignored and + // the connection secret will not contain a password. + // +optional + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` + // ResourceOptions sets account specific resource limits. // See https://dev.mysql.com/doc/refman/8.0/en/user-resources.html // +optional @@ -52,6 +71,20 @@ type UserParameters struct { BinLog *bool `json:"binlog,omitempty"` } +// AuthenticationPlugin selects the auth plugin used when creating the user. +type AuthenticationPlugin struct { + // Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + // "mysql_native_password", "caching_sha2_password", "auth_socket". + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + + // AuthString is the optional auth string passed to the plugin via the + // AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + // password plugins, leave empty and use PasswordSecretRef instead. + // +optional + AuthString *string `json:"authString,omitempty"` +} + // ResourceOptions define the account specific resource limits. type ResourceOptions struct { // MaxQueriesPerHour sets the number of queries an account can issue per hour diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index 9d61f6ba..1bbe3725 100644 --- a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go @@ -13,6 +13,26 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AuthenticationPlugin) DeepCopyInto(out *AuthenticationPlugin) { + *out = *in + if in.AuthString != nil { + in, out := &in.AuthString, &out.AuthString + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationPlugin. +func (in *AuthenticationPlugin) DeepCopy() *AuthenticationPlugin { + if in == nil { + return nil + } + out := new(AuthenticationPlugin) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterProviderConfig) DeepCopyInto(out *ClusterProviderConfig) { *out = *in @@ -775,6 +795,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.LocalSecretKeySelector) **out = **in } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } if in.ResourceOptions != nil { in, out := &in.ResourceOptions, &out.ResourceOptions *out = new(ResourceOptions) diff --git a/examples/cluster/mysql/user_iam_auth.yaml b/examples/cluster/mysql/user_iam_auth.yaml new file mode 100644 index 00000000..8ce97997 --- /dev/null +++ b/examples/cluster/mysql/user_iam_auth.yaml @@ -0,0 +1,22 @@ +--- +# AWS RDS IAM authentication: the DB user has no static password and the +# application authenticates with a short-lived auth token generated from its +# IAM role (see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). +# +# Crossplane runs: +# CREATE USER 'example-iam-user'@'%' IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS' +# +# The connection secret will not contain a password (only username, endpoint, +# port). Clients fetch a temporary auth token from AWS RDS at connect time. +apiVersion: mysql.sql.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-iam-user +spec: + forProvider: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + writeConnectionSecretToRef: + name: example-iam-user-connection + namespace: default diff --git a/examples/namespaced/mysql/user_iam_auth.yaml b/examples/namespaced/mysql/user_iam_auth.yaml new file mode 100644 index 00000000..b34c8e7a --- /dev/null +++ b/examples/namespaced/mysql/user_iam_auth.yaml @@ -0,0 +1,22 @@ +--- +# AWS RDS IAM authentication: the DB user has no static password and the +# application authenticates with a short-lived auth token generated from its +# IAM role (see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAMDBAuth.html). +# +# Crossplane runs: +# CREATE USER 'example-iam-user'@'%' IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS' +# +# The connection secret will not contain a password (only username, endpoint, +# port). Clients fetch a temporary auth token from AWS RDS at connect time. +apiVersion: mysql.sql.m.crossplane.io/v1alpha1 +kind: User +metadata: + name: example-iam-user + namespace: default +spec: + forProvider: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + writeConnectionSecretToRef: + name: example-iam-user-connection diff --git a/package/crds/mysql.sql.crossplane.io_users.yaml b/package/crds/mysql.sql.crossplane.io_users.yaml index 788540e1..4bd34470 100644 --- a/package/crds/mysql.sql.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.crossplane.io_users.yaml @@ -71,6 +71,38 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin selects a non-default MySQL authentication plugin + for the user. When set, the user is created with + CREATE USER ... IDENTIFIED WITH [AS ''] and no + password is generated or required. + + Most commonly used for AWS RDS IAM authentication, where the DB user + has no static password and clients authenticate via short-lived IAM + auth tokens. Example: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + + When AuthenticationPlugin is set, PasswordSecretRef is ignored and + the connection secret will not contain a password. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -79,7 +111,9 @@ spec: passwordSecretRef: description: |- PasswordSecretRef references the secret that contains the password used - for this user. If no reference is given, a password will be auto-generated. + for this user. If no reference is given and AuthenticationPlugin is + unset, a password will be auto-generated. + Ignored when AuthenticationPlugin is set. properties: key: description: The key to select. diff --git a/package/crds/mysql.sql.m.crossplane.io_users.yaml b/package/crds/mysql.sql.m.crossplane.io_users.yaml index 3aab6808..a1f61098 100644 --- a/package/crds/mysql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_users.yaml @@ -57,6 +57,38 @@ spec: description: UserParameters define the desired state of a MySQL user instance. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin selects a non-default MySQL authentication plugin + for the user. When set, the user is created with + CREATE USER ... IDENTIFIED WITH [AS ''] and no + password is generated or required. + + Most commonly used for AWS RDS IAM authentication, where the DB user + has no static password and clients authenticate via short-lived IAM + auth tokens. Example: + authenticationPlugin: + name: AWSAuthenticationPlugin + authString: RDS + + When AuthenticationPlugin is set, PasswordSecretRef is ignored and + the connection secret will not contain a password. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object binlog: description: BinLog defines whether the create, delete, update operations of this user are propagated to replicas. Defaults @@ -65,7 +97,9 @@ spec: passwordSecretRef: description: |- PasswordSecretRef references the secret that contains the password used - for this user. If no reference is given, a password will be auto-generated. + for this user. If no reference is given and AuthenticationPlugin is + unset, a password will be auto-generated. + Ignored when AuthenticationPlugin is set. properties: key: type: string diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index a324f92f..99a84d40 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -229,9 +229,15 @@ 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 + // When the user is configured with a non-default auth plugin (e.g., AWS + // IAM auth), there is no password to compare. Skip the password drift + // check so the resource doesn't churn on every reconcile. + pwdChanged := false + if mg.Spec.ForProvider.AuthenticationPlugin == nil { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) @@ -248,6 +254,23 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + // When AuthenticationPlugin is set, the user has no password — skip + // password generation, emit IDENTIFIED WITH ..., and return connection + // details without a password. + if ap := mg.Spec.ForProvider.AuthenticationPlugin; ap != nil { + if err := c.executeCreateUserWithPluginQuery(ctx, username, host, ro, ap); err != nil { + return managed.ExternalCreation{}, err + } + if len(ro) != 0 { + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + } + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + pw, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err @@ -260,7 +283,6 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter } } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -295,6 +317,38 @@ func (c *external) executeCreateUserQuery(ctx context.Context, username string, return nil } +// executeCreateUserWithPluginQuery emits CREATE USER ... IDENTIFIED WITH +// [AS ''] [WITH ]. The plugin name is +// not value-quoted because MySQL parses it as an identifier; QuoteIdentifier +// (backticks) is used to guard against injection in the plugin name. The +// auth string is value-quoted as a regular string. +func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, ap *v1alpha1.AuthenticationPlugin) error { + resourceOptions := "" + if len(resourceOptionsClauses) != 0 { + resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) + } + + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "CREATE USER %s@%s IDENTIFIED WITH %s%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + resourceOptions, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { + return err + } + + return nil +} + func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) @@ -333,6 +387,13 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter } func (c *external) UpdatePassword(ctx context.Context, cr *v1alpha1.User, username, host string) (managed.ConnectionDetails, error) { + // Users authenticated via a non-default plugin (e.g., AWS IAM auth) + // have no static password to manage. Skip the ALTER USER call so the + // provider doesn't downgrade the user back to native password auth. + if cr.Spec.ForProvider.AuthenticationPlugin != nil { + return managed.ConnectionDetails{}, nil + } + pw, pwchanged, err := c.getPassword(ctx, cr) if err != nil { return managed.ConnectionDetails{}, err diff --git a/pkg/controller/cluster/mysql/user/reconciler_test.go b/pkg/controller/cluster/mysql/user/reconciler_test.go index 97bb7a64..04853e59 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -66,6 +66,8 @@ func (m mockDB) GetConnectionDetails(username, password string) managed.Connecti } } +func ptrString(s string) *string { return &s } + func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } @@ -477,6 +479,101 @@ func TestCreate(t *testing.T) { }, }, }, + "AuthenticationPluginWithAuthString": { + reason: "When AuthenticationPlugin is set the user must be created via IDENTIFIED WITH ... AS, with no password", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("password clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginNoAuthString": { + reason: "When AuthenticationPlugin has no AuthString the AS clause must be omitted", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `auth_socket`") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, " AS ") { + return errors.New("AS clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "auth_socket", + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, } for name, tc := range cases { @@ -627,6 +724,37 @@ func TestUpdate(t *testing.T) { }, want: want{}, }, + "AuthenticationPluginSkipsAlterPassword": { + reason: "When AuthenticationPlugin is set we must not run ALTER USER ... IDENTIFIED BY", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{}, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{ diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index de6f2189..0f33afaf 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -212,9 +212,15 @@ 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 + // When the user is configured with a non-default auth plugin (e.g., AWS + // IAM auth), there is no password to compare. Skip the password drift + // check so the resource doesn't churn on every reconcile. + pwdChanged := false + if mg.Spec.ForProvider.AuthenticationPlugin == nil { + _, pwdChanged, err = c.getPassword(ctx, mg) + if err != nil { + return managed.ExternalObservation{}, err + } } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) @@ -231,6 +237,23 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man mg.SetConditions(xpv1.Creating()) username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + + // When AuthenticationPlugin is set, the user has no password — skip + // password generation, emit IDENTIFIED WITH ..., and return connection + // details without a password. + if ap := mg.Spec.ForProvider.AuthenticationPlugin; ap != nil { + if err := c.executeCreateUserWithPluginQuery(ctx, username, host, ro, ap); err != nil { + return managed.ExternalCreation{}, err + } + if len(ro) != 0 { + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + } + return managed.ExternalCreation{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + pw, _, err := c.getPassword(ctx, mg) if err != nil { return managed.ExternalCreation{}, err @@ -243,7 +266,6 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man } } - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) if err := c.executeCreateUserQuery(ctx, username, host, ro, pw); err != nil { return managed.ExternalCreation{}, err } @@ -278,6 +300,37 @@ func (c *external) executeCreateUserQuery(ctx context.Context, username string, return nil } +// executeCreateUserWithPluginQuery emits CREATE USER ... IDENTIFIED WITH +// [AS ''] [WITH ]. The plugin name is +// wrapped in QuoteIdentifier (backticks) since MySQL parses it as an +// identifier; the auth string is value-quoted as a regular string. +func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, username string, host string, resourceOptionsClauses []string, ap *namespacedv1alpha1.AuthenticationPlugin) error { + resourceOptions := "" + if len(resourceOptionsClauses) != 0 { + resourceOptions = fmt.Sprintf(" WITH %s", strings.Join(resourceOptionsClauses, " ")) + } + + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "CREATE USER %s@%s IDENTIFIED WITH %s%s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + resourceOptions, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errCreateUser}); err != nil { + return err + } + + return nil +} + func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) @@ -316,6 +369,13 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man } func (c *external) UpdatePassword(ctx context.Context, cr *namespacedv1alpha1.User, username, host string) (managed.ConnectionDetails, error) { + // Users authenticated via a non-default plugin (e.g., AWS IAM auth) + // have no static password to manage. Skip the ALTER USER call so the + // provider doesn't downgrade the user back to native password auth. + if cr.Spec.ForProvider.AuthenticationPlugin != nil { + return managed.ConnectionDetails{}, nil + } + pw, pwchanged, err := c.getPassword(ctx, cr) if err != nil { return managed.ConnectionDetails{}, err diff --git a/pkg/controller/namespaced/mysql/user/reconciler_test.go b/pkg/controller/namespaced/mysql/user/reconciler_test.go index 3946ac07..441eb3bf 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -69,6 +69,8 @@ func (m mockDB) GetConnectionDetails(username, password string) managed.Connecti } } +func ptrString(s string) *string { return &s } + func TestConnect(t *testing.T) { errBoom := errors.New("boom") @@ -530,6 +532,101 @@ func TestCreate(t *testing.T) { }, }, }, + "AuthenticationPluginWithAuthString": { + reason: "When AuthenticationPlugin is set the user must be created via IDENTIFIED WITH ... AS, with no password", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("password clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginNoAuthString": { + reason: "When AuthenticationPlugin has no AuthString the AS clause must be omitted", + comparePw: true, + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.HasPrefix(q.String, "CREATE USER ") { + return errors.New("unexpected query: " + q.String) + } + if !strings.Contains(q.String, "IDENTIFIED WITH `auth_socket`") { + return errors.New("expected IDENTIFIED WITH clause, got: " + q.String) + } + if strings.Contains(q.String, " AS ") { + return errors.New("AS clause should not be emitted, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "auth_socket", + }, + }, + }, + }, + }, + want: want{ + err: nil, + c: managed.ExternalCreation{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, } for name, tc := range cases { @@ -680,6 +777,37 @@ func TestUpdate(t *testing.T) { }, want: want{}, }, + "AuthenticationPluginSkipsAlterPassword": { + reason: "When AuthenticationPlugin is set we must not run ALTER USER ... IDENTIFIED BY", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if strings.Contains(q.String, "IDENTIFIED BY") { + return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{}, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{ From af1d37fd0451735e78e458bc5e0c2f05b5226b7a Mon Sep 17 00:00:00 2001 From: Ron Levy Date: Mon, 4 May 2026 11:39:46 +0300 Subject: [PATCH 2/4] feat(mysql/user): make passwordSecretRef and authenticationPlugin mutually exclusive Add a CRD-level XValidation rule rejecting any User where both passwordSecretRef and authenticationPlugin are set, addressing review feedback. The runtime branches in Observe/Create/UpdatePassword stay because they encode the semantic that plugin-auth users have no password (they are not defensive guards against both fields being set). Tighten doc comments on both fields to reflect that the two cannot coexist. Signed-off-by: Ron Levy --- apis/cluster/mysql/v1alpha1/user_types.go | 10 +++++----- apis/namespaced/mysql/v1alpha1/user_types.go | 10 +++++----- package/crds/mysql.sql.crossplane.io_users.yaml | 13 ++++++++----- package/crds/mysql.sql.m.crossplane.io_users.yaml | 13 ++++++++----- 4 files changed, 26 insertions(+), 20 deletions(-) diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index b43cc406..600e12d4 100644 --- a/apis/cluster/mysql/v1alpha1/user_types.go +++ b/apis/cluster/mysql/v1alpha1/user_types.go @@ -35,11 +35,11 @@ type UserStatus struct { } // UserParameters define the desired state of a MySQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.authenticationPlugin) && has(self.passwordSecretRef))",message="authenticationPlugin and passwordSecretRef are mutually exclusive" type UserParameters struct { // PasswordSecretRef references the secret that contains the password used - // for this user. If no reference is given and AuthenticationPlugin is - // unset, a password will be auto-generated. - // Ignored when AuthenticationPlugin is set. + // for this user. If no reference is given, a password will be auto-generated. + // Cannot be set together with AuthenticationPlugin. // +optional PasswordSecretRef *xpv1.SecretKeySelector `json:"passwordSecretRef,omitempty"` @@ -55,8 +55,8 @@ type UserParameters struct { // name: AWSAuthenticationPlugin // authString: RDS // - // When AuthenticationPlugin is set, PasswordSecretRef is ignored and - // the connection secret will not contain a password. + // When AuthenticationPlugin is set, PasswordSecretRef must not be set + // and the connection secret will not contain a password. // +optional AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` diff --git a/apis/namespaced/mysql/v1alpha1/user_types.go b/apis/namespaced/mysql/v1alpha1/user_types.go index 4bff8c2b..313c6b87 100644 --- a/apis/namespaced/mysql/v1alpha1/user_types.go +++ b/apis/namespaced/mysql/v1alpha1/user_types.go @@ -36,11 +36,11 @@ type UserStatus struct { } // UserParameters define the desired state of a MySQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.authenticationPlugin) && has(self.passwordSecretRef))",message="authenticationPlugin and passwordSecretRef are mutually exclusive" type UserParameters struct { // PasswordSecretRef references the secret that contains the password used - // for this user. If no reference is given and AuthenticationPlugin is - // unset, a password will be auto-generated. - // Ignored when AuthenticationPlugin is set. + // for this user. If no reference is given, a password will be auto-generated. + // Cannot be set together with AuthenticationPlugin. // +optional PasswordSecretRef *xpv1.LocalSecretKeySelector `json:"passwordSecretRef,omitempty"` @@ -56,8 +56,8 @@ type UserParameters struct { // name: AWSAuthenticationPlugin // authString: RDS // - // When AuthenticationPlugin is set, PasswordSecretRef is ignored and - // the connection secret will not contain a password. + // When AuthenticationPlugin is set, PasswordSecretRef must not be set + // and the connection secret will not contain a password. // +optional AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` diff --git a/package/crds/mysql.sql.crossplane.io_users.yaml b/package/crds/mysql.sql.crossplane.io_users.yaml index 4bd34470..798cf053 100644 --- a/package/crds/mysql.sql.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.crossplane.io_users.yaml @@ -85,8 +85,8 @@ spec: name: AWSAuthenticationPlugin authString: RDS - When AuthenticationPlugin is set, PasswordSecretRef is ignored and - the connection secret will not contain a password. + When AuthenticationPlugin is set, PasswordSecretRef must not be set + and the connection secret will not contain a password. properties: authString: description: |- @@ -111,9 +111,8 @@ spec: passwordSecretRef: description: |- PasswordSecretRef references the secret that contains the password used - for this user. If no reference is given and AuthenticationPlugin is - unset, a password will be auto-generated. - Ignored when AuthenticationPlugin is set. + for this user. If no reference is given, a password will be auto-generated. + Cannot be set together with AuthenticationPlugin. properties: key: description: The key to select. @@ -152,6 +151,10 @@ spec: type: integer type: object type: object + x-kubernetes-validations: + - message: authenticationPlugin and passwordSecretRef are mutually + exclusive + rule: '!(has(self.authenticationPlugin) && has(self.passwordSecretRef))' managementPolicies: default: - '*' diff --git a/package/crds/mysql.sql.m.crossplane.io_users.yaml b/package/crds/mysql.sql.m.crossplane.io_users.yaml index a1f61098..afb70e77 100644 --- a/package/crds/mysql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_users.yaml @@ -71,8 +71,8 @@ spec: name: AWSAuthenticationPlugin authString: RDS - When AuthenticationPlugin is set, PasswordSecretRef is ignored and - the connection secret will not contain a password. + When AuthenticationPlugin is set, PasswordSecretRef must not be set + and the connection secret will not contain a password. properties: authString: description: |- @@ -97,9 +97,8 @@ spec: passwordSecretRef: description: |- PasswordSecretRef references the secret that contains the password used - for this user. If no reference is given and AuthenticationPlugin is - unset, a password will be auto-generated. - Ignored when AuthenticationPlugin is set. + for this user. If no reference is given, a password will be auto-generated. + Cannot be set together with AuthenticationPlugin. properties: key: type: string @@ -133,6 +132,10 @@ spec: type: integer type: object type: object + x-kubernetes-validations: + - message: authenticationPlugin and passwordSecretRef are mutually + exclusive + rule: '!(has(self.authenticationPlugin) && has(self.passwordSecretRef))' managementPolicies: default: - '*' From 12cd32253a438c5830cb8857e5d55a44c619a027 Mon Sep 17 00:00:00 2001 From: ronlevy Date: Sun, 17 May 2026 09:25:14 +0300 Subject: [PATCH 3/4] feat(mysql/user): plugin drift detection and Update support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address @chlunde's review on #363: Observe didn't read the user's actual `plugin` from mysql.user, and Update silently ignored authenticationPlugin changes. Crossplane managed resources should reconcile every field the upstream system supports. Changes: Observe — extend the SELECT to also fetch `plugin` and `authentication_string`. Hydrate observed.AuthenticationPlugin only when the observed plugin is a non-default-password plugin (caching_sha2_password / mysql_native_password / sha256_password represent "use a password" rather than an opt-in plugin choice and are left as nil in the observed state). upToDate — compare AuthenticationPlugin (name + authString). nil vs non-nil is a drift; same nil-ness with matching name+authString is up-to-date. Update — when authenticationPlugin drift is detected and the desired spec sets a plugin, emit ALTER USER @ IDENTIFIED WITH [AS ''] via a new executeAlterUserWithPluginQuery helper that mirrors the Create-time query's quoting rules (identifier-quote the plugin name, value-quote the authString). When the desired spec drops the plugin (plugin → password transition), the existing UpdatePassword path is reused — ALTER USER ... IDENTIFIED BY '' both sets the password and restores the default password plugin in one statement. Status — UserObservation gains an AuthenticationPlugin field, set by Observe and consumed by Update for drift detection across reconciliation cycles. Tests cover three new transitions: - password → plugin (observed nil, desired set) - plugin → different authString (same name, different AS clause) - existing AuthenticationPluginSkipsAlterPassword updated to set observed == desired and now asserts no IDENTIFIED WITH OR BY runs Signed-off-by: ronlevy --- apis/cluster/mysql/v1alpha1/user_types.go | 8 ++ .../mysql/v1alpha1/zz_generated.deepcopy.go | 5 + apis/namespaced/mysql/v1alpha1/user_types.go | 8 ++ .../mysql/v1alpha1/zz_generated.deepcopy.go | 5 + .../cluster/mysql/user/reconciler.go | 120 +++++++++++++++++- .../cluster/mysql/user/reconciler_test.go | 104 ++++++++++++++- .../namespaced/mysql/user/reconciler.go | 120 +++++++++++++++++- .../namespaced/mysql/user/reconciler_test.go | 101 ++++++++++++++- 8 files changed, 467 insertions(+), 4 deletions(-) diff --git a/apis/cluster/mysql/v1alpha1/user_types.go b/apis/cluster/mysql/v1alpha1/user_types.go index 600e12d4..cae393f8 100644 --- a/apis/cluster/mysql/v1alpha1/user_types.go +++ b/apis/cluster/mysql/v1alpha1/user_types.go @@ -107,6 +107,14 @@ type ResourceOptions struct { type UserObservation struct { // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` + + // AuthenticationPlugin reflects the user's actual authentication plugin + // as read from mysql.user. Populated only when the observed plugin is a + // non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + // the reconciler to detect drift between the desired + // spec.forProvider.authenticationPlugin and what's actually configured + // on the DB so plugin changes are not silently ignored. + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,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 8df78fd2..5ea9b6eb 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.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserObservation. diff --git a/apis/namespaced/mysql/v1alpha1/user_types.go b/apis/namespaced/mysql/v1alpha1/user_types.go index 313c6b87..fec67c90 100644 --- a/apis/namespaced/mysql/v1alpha1/user_types.go +++ b/apis/namespaced/mysql/v1alpha1/user_types.go @@ -108,6 +108,14 @@ type ResourceOptions struct { type UserObservation struct { // ResourceOptionsAsClauses represents the applied resource options ResourceOptionsAsClauses []string `json:"resourceOptionsAsClauses,omitempty"` + + // AuthenticationPlugin reflects the user's actual authentication plugin + // as read from mysql.user. Populated only when the observed plugin is a + // non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + // the reconciler to detect drift between the desired + // spec.forProvider.authenticationPlugin and what's actually configured + // on the DB so plugin changes are not silently ignored. + AuthenticationPlugin *AuthenticationPlugin `json:"authenticationPlugin,omitempty"` } // +kubebuilder:object:root=true diff --git a/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mysql/v1alpha1/zz_generated.deepcopy.go index 1bbe3725..b614a981 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 *UserObservation) DeepCopyInto(out *UserObservation) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.AuthenticationPlugin != nil { + in, out := &in.AuthenticationPlugin, &out.AuthenticationPlugin + *out = new(AuthenticationPlugin) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserObservation. diff --git a/pkg/controller/cluster/mysql/user/reconciler.go b/pkg/controller/cluster/mysql/user/reconciler.go index 99a84d40..44eaecb6 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -203,11 +203,14 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte ResourceOptions: &v1alpha1.ResourceOptions{}, } + var pluginName, authString string query := "SELECT " + "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin, " + + "authentication_string " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -221,6 +224,8 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &pluginName, + &authString, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -229,6 +234,21 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } + // Hydrate observed.AuthenticationPlugin only when the user is configured + // with a non-default-password plugin. Password plugins (caching_sha2_password, + // mysql_native_password, sha256_password) store an opaque hash in + // authentication_string and are the implicit default when the user was + // created with IDENTIFIED BY '' — we leave AuthenticationPlugin nil in + // that case so the comparison against a spec without AuthenticationPlugin + // reports up-to-date. + if pluginName != "" && !isPasswordPlugin(pluginName) { + observed.AuthenticationPlugin = &v1alpha1.AuthenticationPlugin{Name: pluginName} + if authString != "" { + as := authString + observed.AuthenticationPlugin.AuthString = &as + } + } + // When the user is configured with a non-default auth plugin (e.g., AWS // IAM auth), there is no password to compare. Skip the password drift // check so the resource doesn't churn on every reconcile. @@ -241,6 +261,7 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthenticationPlugin = observed.AuthenticationPlugin mg.SetConditions(xpv1.Available()) @@ -250,6 +271,43 @@ func (c *external) Observe(ctx context.Context, mg *v1alpha1.User) (managed.Exte }, nil } +// isPasswordPlugin reports whether the given MySQL plugin name is one of the +// password-based authentication plugins. Users created with IDENTIFIED BY +// '' are assigned a password plugin (caching_sha2_password is the MySQL +// 8.0 default; mysql_native_password and sha256_password are common). For +// these we treat AuthenticationPlugin as nil in the observed state because +// they represent "use a password" rather than an opt-in plugin choice. +func isPasswordPlugin(name string) bool { + switch name { + case "caching_sha2_password", "mysql_native_password", "sha256_password": + return true + } + return false +} + +// authPluginEqual reports whether observed and desired AuthenticationPlugin +// configurations match. nil/nil = match. Mismatched nil-ness = drift. Same +// nil-ness with non-nil values: names must match, and authStrings must both +// be nil OR both equal. +func authPluginEqual(observed, desired *v1alpha1.AuthenticationPlugin) bool { + if desired == nil { + return observed == nil + } + if observed == nil { + return false + } + if observed.Name != desired.Name { + return false + } + if (observed.AuthString == nil) != (desired.AuthString == nil) { + return false + } + if observed.AuthString != nil && *observed.AuthString != *desired.AuthString { + return false + } + return true +} + func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) @@ -374,11 +432,41 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter mg.Status.AtProvider.ResourceOptionsAsClauses = ro } + // Detect AuthenticationPlugin drift using the last-observed value cached + // in Status.AtProvider by Observe. Three transitions are handled: + // 1. password → plugin: ALTER USER ... IDENTIFIED WITH [AS ''] + // 2. plugin → password: fall through to UpdatePassword below; it emits + // ALTER USER ... IDENTIFIED BY '' which both sets the password + // AND restores the default password plugin. + // 3. plugin → different plugin or same plugin with different authString: + // ALTER USER ... IDENTIFIED WITH [AS ''] + // The API-level XValidation rule enforces mutual exclusivity of + // PasswordSecretRef and AuthenticationPlugin, so the spec always + // represents one mode or the other. + desiredPlugin := mg.Spec.ForProvider.AuthenticationPlugin + observedPlugin := mg.Status.AtProvider.AuthenticationPlugin + if !authPluginEqual(observedPlugin, desiredPlugin) && desiredPlugin != nil { + if err := c.executeAlterUserWithPluginQuery(ctx, username, host, desiredPlugin); err != nil { + return managed.ExternalUpdate{}, err + } + mg.Status.AtProvider.AuthenticationPlugin = desiredPlugin + // Connection details for a plugin-auth user carry no password. + return managed.ExternalUpdate{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + connectionDetails, err := c.UpdatePassword(ctx, mg, username, host) if err != nil { return managed.ExternalUpdate{}, err } + // If transitioning from plugin → password, clear the cached observed + // plugin so the next reconcile starts clean. + if desiredPlugin == nil && observedPlugin != nil { + mg.Status.AtProvider.AuthenticationPlugin = nil + } + if len(connectionDetails) > 0 { return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil } @@ -386,6 +474,31 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalUpdate{}, nil } +// executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH +// [AS '']. Same identifier-vs-value quoting rules as +// the Create-time helper: plugin name is QuoteIdentifier (backticked) to +// guard against injection while still parsing as an identifier, authString +// is value-quoted as a regular string. +func (c *external) executeAlterUserWithPluginQuery(ctx context.Context, username string, host string, ap *v1alpha1.AuthenticationPlugin) error { + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "ALTER USER %s@%s IDENTIFIED WITH %s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + return nil +} + func (c *external) UpdatePassword(ctx context.Context, cr *v1alpha1.User, username, host string) (managed.ConnectionDetails, error) { // Users authenticated via a non-default plugin (e.g., AWS IAM auth) // have no static password to manage. Skip the ALTER USER call so the @@ -429,6 +542,11 @@ func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.Exter } func upToDate(observed *v1alpha1.UserParameters, desired *v1alpha1.UserParameters) bool { + // AuthenticationPlugin drift — checked first so a plugin change is detected + // even when ResourceOptions are nil. + if !authPluginEqual(observed.AuthenticationPlugin, desired.AuthenticationPlugin) { + return false + } 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 04853e59..ba5a9186 100644 --- a/pkg/controller/cluster/mysql/user/reconciler_test.go +++ b/pkg/controller/cluster/mysql/user/reconciler_test.go @@ -725,13 +725,16 @@ func TestUpdate(t *testing.T) { want: want{}, }, "AuthenticationPluginSkipsAlterPassword": { - reason: "When AuthenticationPlugin is set we must not run ALTER USER ... IDENTIFIED BY", + reason: "When AuthenticationPlugin is already applied (observed == desired), Update must be a no-op for the password path — no ALTER USER ... IDENTIFIED BY", fields: fields{ db: &mockDB{ MockExec: func(ctx context.Context, q xsql.Query) error { if strings.Contains(q.String, "IDENTIFIED BY") { return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) } + if strings.Contains(q.String, "IDENTIFIED WITH") { + return errors.New("ALTER USER ... IDENTIFIED WITH must not run when observed plugin matches desired, got: " + q.String) + } return nil }, }, @@ -751,10 +754,109 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, }, }, want: want{}, }, + "AuthenticationPluginDriftPasswordToPlugin": { + reason: "When observed has no plugin (password user) and desired has AuthenticationPlugin, Update must emit ALTER USER ... IDENTIFIED WITH", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected ALTER USER ... IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS', got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + // Status.AtProvider.AuthenticationPlugin intentionally nil — + // represents an existing password-auth user that's now being + // switched to plugin auth. + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginDriftAuthStringChange": { + reason: "When observed plugin matches desired name but authString differs, Update must emit ALTER USER ... IDENTIFIED WITH for the new authString", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS2'") { + return errors.New("expected ALTER USER with new authString RDS2, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: v1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS2"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{ diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 0f33afaf..28bfc01f 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -186,11 +186,14 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma ResourceOptions: &namespacedv1alpha1.ResourceOptions{}, } + var pluginName, authString string query := "SELECT " + "max_questions, " + "max_updates, " + "max_connections, " + - "max_user_connections " + + "max_user_connections, " + + "plugin, " + + "authentication_string " + "FROM mysql.user WHERE User = ? AND Host = ?" err := c.db.Scan(ctx, xsql.Query{ @@ -204,6 +207,8 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma &observed.ResourceOptions.MaxUpdatesPerHour, &observed.ResourceOptions.MaxConnectionsPerHour, &observed.ResourceOptions.MaxUserConnections, + &pluginName, + &authString, ) if xsql.IsNoRows(err) { return managed.ExternalObservation{ResourceExists: false}, nil @@ -212,6 +217,21 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma return managed.ExternalObservation{}, errors.Wrap(err, errSelectUser) } + // Hydrate observed.AuthenticationPlugin only when the user is configured + // with a non-default-password plugin. Password plugins (caching_sha2_password, + // mysql_native_password, sha256_password) store an opaque hash in + // authentication_string and are the implicit default when the user was + // created with IDENTIFIED BY '' — we leave AuthenticationPlugin nil in + // that case so the comparison against a spec without AuthenticationPlugin + // reports up-to-date. + if pluginName != "" && !isPasswordPlugin(pluginName) { + observed.AuthenticationPlugin = &namespacedv1alpha1.AuthenticationPlugin{Name: pluginName} + if authString != "" { + as := authString + observed.AuthenticationPlugin.AuthString = &as + } + } + // When the user is configured with a non-default auth plugin (e.g., AWS // IAM auth), there is no password to compare. Skip the password drift // check so the resource doesn't churn on every reconcile. @@ -224,6 +244,7 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma } mg.Status.AtProvider.ResourceOptionsAsClauses = resourceOptionsToClauses(observed.ResourceOptions) + mg.Status.AtProvider.AuthenticationPlugin = observed.AuthenticationPlugin mg.SetConditions(xpv1.Available()) @@ -233,6 +254,43 @@ func (c *external) Observe(ctx context.Context, mg *namespacedv1alpha1.User) (ma }, nil } +// isPasswordPlugin reports whether the given MySQL plugin name is one of the +// password-based authentication plugins. Users created with IDENTIFIED BY +// '' are assigned a password plugin (caching_sha2_password is the MySQL +// 8.0 default; mysql_native_password and sha256_password are common). For +// these we treat AuthenticationPlugin as nil in the observed state because +// they represent "use a password" rather than an opt-in plugin choice. +func isPasswordPlugin(name string) bool { + switch name { + case "caching_sha2_password", "mysql_native_password", "sha256_password": + return true + } + return false +} + +// authPluginEqual reports whether observed and desired AuthenticationPlugin +// configurations match. nil/nil = match. Mismatched nil-ness = drift. Same +// nil-ness with non-nil values: names must match, and authStrings must both +// be nil OR both equal. +func authPluginEqual(observed, desired *namespacedv1alpha1.AuthenticationPlugin) bool { + if desired == nil { + return observed == nil + } + if observed == nil { + return false + } + if observed.Name != desired.Name { + return false + } + if (observed.AuthString == nil) != (desired.AuthString == nil) { + return false + } + if observed.AuthString != nil && *observed.AuthString != *desired.AuthString { + return false + } + return true +} + func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalCreation, error) { mg.SetConditions(xpv1.Creating()) @@ -356,11 +414,41 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man mg.Status.AtProvider.ResourceOptionsAsClauses = ro } + // Detect AuthenticationPlugin drift using the last-observed value cached + // in Status.AtProvider by Observe. Three transitions are handled: + // 1. password → plugin: ALTER USER ... IDENTIFIED WITH [AS ''] + // 2. plugin → password: fall through to UpdatePassword below; it emits + // ALTER USER ... IDENTIFIED BY '' which both sets the password + // AND restores the default password plugin. + // 3. plugin → different plugin or same plugin with different authString: + // ALTER USER ... IDENTIFIED WITH [AS ''] + // The API-level XValidation rule enforces mutual exclusivity of + // PasswordSecretRef and AuthenticationPlugin, so the spec always + // represents one mode or the other. + desiredPlugin := mg.Spec.ForProvider.AuthenticationPlugin + observedPlugin := mg.Status.AtProvider.AuthenticationPlugin + if !authPluginEqual(observedPlugin, desiredPlugin) && desiredPlugin != nil { + if err := c.executeAlterUserWithPluginQuery(ctx, username, host, desiredPlugin); err != nil { + return managed.ExternalUpdate{}, err + } + mg.Status.AtProvider.AuthenticationPlugin = desiredPlugin + // Connection details for a plugin-auth user carry no password. + return managed.ExternalUpdate{ + ConnectionDetails: c.db.GetConnectionDetails(username, ""), + }, nil + } + connectionDetails, err := c.UpdatePassword(ctx, mg, username, host) if err != nil { return managed.ExternalUpdate{}, err } + // If transitioning from plugin → password, clear the cached observed + // plugin so the next reconcile starts clean. + if desiredPlugin == nil && observedPlugin != nil { + mg.Status.AtProvider.AuthenticationPlugin = nil + } + if len(connectionDetails) > 0 { return managed.ExternalUpdate{ConnectionDetails: connectionDetails}, nil } @@ -368,6 +456,31 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalUpdate{}, nil } +// executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH +// [AS '']. Same identifier-vs-value quoting rules as +// the Create-time helper: plugin name is QuoteIdentifier (backticked) to +// guard against injection while still parsing as an identifier, authString +// is value-quoted as a regular string. +func (c *external) executeAlterUserWithPluginQuery(ctx context.Context, username string, host string, ap *namespacedv1alpha1.AuthenticationPlugin) error { + authString := "" + if ap.AuthString != nil && *ap.AuthString != "" { + authString = fmt.Sprintf(" AS %s", mysql.QuoteValue(*ap.AuthString)) + } + + query := fmt.Sprintf( + "ALTER USER %s@%s IDENTIFIED WITH %s%s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + mysql.QuoteIdentifier(ap.Name), + authString, + ) + + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + return nil +} + func (c *external) UpdatePassword(ctx context.Context, cr *namespacedv1alpha1.User, username, host string) (managed.ConnectionDetails, error) { // Users authenticated via a non-default plugin (e.g., AWS IAM auth) // have no static password to manage. Skip the ALTER USER call so the @@ -411,6 +524,11 @@ func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (man } func upToDate(observed *namespacedv1alpha1.UserParameters, desired *namespacedv1alpha1.UserParameters) bool { + // AuthenticationPlugin drift — checked first so a plugin change is detected + // even when ResourceOptions are nil. + if !authPluginEqual(observed.AuthenticationPlugin, desired.AuthenticationPlugin) { + return false + } 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 441eb3bf..345820e3 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler_test.go +++ b/pkg/controller/namespaced/mysql/user/reconciler_test.go @@ -778,13 +778,16 @@ func TestUpdate(t *testing.T) { want: want{}, }, "AuthenticationPluginSkipsAlterPassword": { - reason: "When AuthenticationPlugin is set we must not run ALTER USER ... IDENTIFIED BY", + reason: "When AuthenticationPlugin is already applied (observed == desired), Update must be a no-op for the password path — no ALTER USER ... IDENTIFIED BY", fields: fields{ db: &mockDB{ MockExec: func(ctx context.Context, q xsql.Query) error { if strings.Contains(q.String, "IDENTIFIED BY") { return errors.New("ALTER USER ... IDENTIFIED BY must not be run for plugin-auth users, got: " + q.String) } + if strings.Contains(q.String, "IDENTIFIED WITH") { + return errors.New("ALTER USER ... IDENTIFIED WITH must not run when observed plugin matches desired, got: " + q.String) + } return nil }, }, @@ -804,10 +807,106 @@ func TestUpdate(t *testing.T) { }, }, }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, }, }, want: want{}, }, + "AuthenticationPluginDriftPasswordToPlugin": { + reason: "When observed has no plugin (password user) and desired has AuthenticationPlugin, Update must emit ALTER USER ... IDENTIFIED WITH", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS'") { + return errors.New("expected ALTER USER ... IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS', got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, + "AuthenticationPluginDriftAuthStringChange": { + reason: "When observed plugin matches desired name but authString differs, Update must emit ALTER USER ... IDENTIFIED WITH for the new authString", + fields: fields{ + db: &mockDB{ + MockExec: func(ctx context.Context, q xsql.Query) error { + if !strings.Contains(q.String, "IDENTIFIED WITH `AWSAuthenticationPlugin` AS 'RDS2'") { + return errors.New("expected ALTER USER with new authString RDS2, got: " + q.String) + } + return nil + }, + }, + }, + args: args{ + mg: &v1alpha1.User{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + meta.AnnotationKeyExternalName: "example", + }, + }, + Spec: v1alpha1.UserSpec{ + ForProvider: v1alpha1.UserParameters{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS2"), + }, + }, + }, + Status: v1alpha1.UserStatus{ + AtProvider: v1alpha1.UserObservation{ + AuthenticationPlugin: &v1alpha1.AuthenticationPlugin{ + Name: "AWSAuthenticationPlugin", + AuthString: ptrString("RDS"), + }, + }, + }, + }, + }, + want: want{ + c: managed.ExternalUpdate{ + ConnectionDetails: managed.ConnectionDetails{ + xpv1.ResourceCredentialsSecretUserKey: []byte("example"), + xpv1.ResourceCredentialsSecretPasswordKey: []byte(""), + xpv1.ResourceCredentialsSecretEndpointKey: []byte("localhost"), + xpv1.ResourceCredentialsSecretPortKey: []byte("3306"), + }, + }, + }, + }, "UpdatePassword": { reason: "The password must be updated", fields: fields{ From a3aedb16f7689a244c1df3d3166b378ee6f64a42 Mon Sep 17 00:00:00 2001 From: ronlevy Date: Mon, 18 May 2026 08:42:39 +0300 Subject: [PATCH 4/4] fix(mysql/user): keep Update under gocyclo limit + regen CRDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the post-master-merge HEAD failed on two checks: - lint (gocyclo) — the Update function in both cluster and namespaced reconcilers had cyclomatic complexity 11 (limit 10). Extracted the resource-options drift branch into a small updateResourceOptions helper. Behavior unchanged: same ALTER USER WITH emission, same status writeback, same error wrapping. - check-diff — package/crds/*.yaml were stale: the AuthenticationPlugin field added to UserObservation in the previous commit was not yet reflected in the generated CRDs. Ran `make generate` so the rendered schema matches the typed Go source. Tests pass on both packages. Signed-off-by: ronlevy --- .../crds/mysql.sql.crossplane.io_users.yaml | 24 ++++++++++ .../crds/mysql.sql.m.crossplane.io_users.yaml | 24 ++++++++++ .../cluster/mysql/user/reconciler.go | 48 +++++++++++-------- .../namespaced/mysql/user/reconciler.go | 48 +++++++++++-------- 4 files changed, 104 insertions(+), 40 deletions(-) diff --git a/package/crds/mysql.sql.crossplane.io_users.yaml b/package/crds/mysql.sql.crossplane.io_users.yaml index 5616059d..a14e6c68 100644 --- a/package/crds/mysql.sql.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.crossplane.io_users.yaml @@ -248,6 +248,30 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin reflects the user's actual authentication plugin + as read from mysql.user. Populated only when the observed plugin is a + non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + the reconciler to detect drift between the desired + spec.forProvider.authenticationPlugin and what's actually configured + on the DB so plugin changes are not silently ignored. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object 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 bb69eb39..8ff1932e 100644 --- a/package/crds/mysql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mysql.sql.m.crossplane.io_users.yaml @@ -201,6 +201,30 @@ spec: description: A UserObservation represents the observed state of a MySQL user. properties: + authenticationPlugin: + description: |- + AuthenticationPlugin reflects the user's actual authentication plugin + as read from mysql.user. Populated only when the observed plugin is a + non-default-password plugin (e.g., AWSAuthenticationPlugin). Used by + the reconciler to detect drift between the desired + spec.forProvider.authenticationPlugin and what's actually configured + on the DB so plugin changes are not silently ignored. + properties: + authString: + description: |- + AuthString is the optional auth string passed to the plugin via the + AS clause. For AWSAuthenticationPlugin set this to "RDS". For native + password plugins, leave empty and use PasswordSecretRef instead. + type: string + name: + description: |- + Name is the auth plugin name. Examples: "AWSAuthenticationPlugin", + "mysql_native_password", "caching_sha2_password", "auth_socket". + minLength: 1 + type: string + required: + - name + type: object 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 dfe1dd0b..3a7e21b9 100644 --- a/pkg/controller/cluster/mysql/user/reconciler.go +++ b/pkg/controller/cluster/mysql/user/reconciler.go @@ -411,26 +411,8 @@ func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, usernam func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) - } - - 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 { - return managed.ExternalUpdate{}, err - } - - mg.Status.AtProvider.ResourceOptionsAsClauses = ro + if err := c.updateResourceOptions(ctx, mg, username, host); err != nil { + return managed.ExternalUpdate{}, err } // Detect AuthenticationPlugin drift using the last-observed value cached @@ -475,6 +457,32 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalUpdate{}, nil } +// updateResourceOptions emits ALTER USER ... WITH when the +// resource-options clauses observed by Status differ from the desired spec. +// Extracted from Update to keep that function below the gocyclo threshold. +func (c *external) updateResourceOptions(ctx context.Context, mg *v1alpha1.User, username, host string) error { + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return errors.Wrap(err, errUpdateUser) + } + if len(rochanged) == 0 { + return nil + } + + query := fmt.Sprintf( + "ALTER USER %s@%s WITH %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + strings.Join(ro, " "), + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + return nil +} + // executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH // [AS '']. Same identifier-vs-value quoting rules as // the Create-time helper: plugin name is QuoteIdentifier (backticked) to diff --git a/pkg/controller/namespaced/mysql/user/reconciler.go b/pkg/controller/namespaced/mysql/user/reconciler.go index 28bfc01f..59f33753 100644 --- a/pkg/controller/namespaced/mysql/user/reconciler.go +++ b/pkg/controller/namespaced/mysql/user/reconciler.go @@ -392,26 +392,8 @@ func (c *external) executeCreateUserWithPluginQuery(ctx context.Context, usernam func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalUpdate, error) { username, host := mysql.SplitUserHost(meta.GetExternalName(mg)) - ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) - rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) - if err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) - } - - 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 { - return managed.ExternalUpdate{}, err - } - - mg.Status.AtProvider.ResourceOptionsAsClauses = ro + if err := c.updateResourceOptions(ctx, mg, username, host); err != nil { + return managed.ExternalUpdate{}, err } // Detect AuthenticationPlugin drift using the last-observed value cached @@ -456,6 +438,32 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalUpdate{}, nil } +// updateResourceOptions emits ALTER USER ... WITH when the +// resource-options clauses observed by Status differ from the desired spec. +// Extracted from Update to keep that function below the gocyclo threshold. +func (c *external) updateResourceOptions(ctx context.Context, mg *namespacedv1alpha1.User, username, host string) error { + ro := resourceOptionsToClauses(mg.Spec.ForProvider.ResourceOptions) + rochanged, err := changedResourceOptions(mg.Status.AtProvider.ResourceOptionsAsClauses, ro) + if err != nil { + return errors.Wrap(err, errUpdateUser) + } + if len(rochanged) == 0 { + return nil + } + + query := fmt.Sprintf( + "ALTER USER %s@%s WITH %s", + mysql.QuoteValue(username), + mysql.QuoteValue(host), + strings.Join(ro, " "), + ) + if err := mysql.ExecWrapper(ctx, c.db, mysql.ExecQuery{Query: query, ErrorValue: errUpdateUser}); err != nil { + return err + } + mg.Status.AtProvider.ResourceOptionsAsClauses = ro + return nil +} + // executeAlterUserWithPluginQuery emits ALTER USER ... IDENTIFIED WITH // [AS '']. Same identifier-vs-value quoting rules as // the Create-time helper: plugin name is QuoteIdentifier (backticked) to