diff --git a/apis/cluster/mssql/v1alpha1/user_types.go b/apis/cluster/mssql/v1alpha1/user_types.go index 3b41bf01..baf4883e 100644 --- a/apis/cluster/mssql/v1alpha1/user_types.go +++ b/apis/cluster/mssql/v1alpha1/user_types.go @@ -35,6 +35,8 @@ type UserStatus struct { } // UserParameters define the desired state of a MSSQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.contained) && self.contained == true && (has(self.loginDatabase) || has(self.loginDatabaseRef) || has(self.loginDatabaseSelector)))",message="contained users cannot specify loginDatabase, loginDatabaseRef, or loginDatabaseSelector" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.contained) || self.contained == oldSelf.contained",message="contained field is immutable after creation" type UserParameters struct { // Database allows you to specify the name of the Database the USER is created for. // +crossplane:generate:reference:type=Database @@ -56,6 +58,11 @@ type UserParameters struct { LoginDatabaseRef *xpv1.Reference `json:"loginDatabaseRef,omitempty"` // DatabaseSelector allows you to use selector constraints to select a Database to be used to create the user LOGIN in (normally master). LoginDatabaseSelector *xpv1.Selector `json:"loginDatabaseSelector,omitempty"` + // Contained specifies whether to create a contained database user (without server-level login). + // When true, the user will be created directly in the specified database using CREATE USER WITH PASSWORD. + // When false (default), a server-level LOGIN will be created first, then a database user mapped to that login. + // +optional + Contained *bool `json:"contained,omitempty"` } // A UserObservation represents the observed state of a MSSQL user. diff --git a/apis/cluster/mssql/v1alpha1/zz_generated.deepcopy.go b/apis/cluster/mssql/v1alpha1/zz_generated.deepcopy.go index ec8dd3c1..afdf9da9 100644 --- a/apis/cluster/mssql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/cluster/mssql/v1alpha1/zz_generated.deepcopy.go @@ -551,6 +551,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.Selector) (*in).DeepCopyInto(*out) } + if in.Contained != nil { + in, out := &in.Contained, &out.Contained + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserParameters. diff --git a/apis/namespaced/mssql/v1alpha1/user_types.go b/apis/namespaced/mssql/v1alpha1/user_types.go index 59a1cfb8..b269a947 100644 --- a/apis/namespaced/mssql/v1alpha1/user_types.go +++ b/apis/namespaced/mssql/v1alpha1/user_types.go @@ -36,6 +36,8 @@ type UserStatus struct { } // UserParameters define the desired state of a MSSQL user instance. +// +kubebuilder:validation:XValidation:rule="!(has(self.contained) && self.contained == true && (has(self.loginDatabase) || has(self.loginDatabaseRef) || has(self.loginDatabaseSelector)))",message="contained users cannot specify loginDatabase, loginDatabaseRef, or loginDatabaseSelector" +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.contained) || self.contained == oldSelf.contained",message="contained field is immutable after creation" type UserParameters struct { // Database allows you to specify the name of the Database the USER is created for. // +crossplane:generate:reference:type=Database @@ -57,6 +59,11 @@ type UserParameters struct { LoginDatabaseRef *xpv1.NamespacedReference `json:"loginDatabaseRef,omitempty"` // DatabaseSelector allows you to use selector constraints to select a Database to be used to create the user LOGIN in (normally master). LoginDatabaseSelector *xpv1.NamespacedSelector `json:"loginDatabaseSelector,omitempty"` + // Contained specifies whether to create a contained database user (without server-level login). + // When true, the user will be created directly in the specified database using CREATE USER WITH PASSWORD. + // When false (default), a server-level LOGIN will be created first, then a database user mapped to that login. + // +optional + Contained *bool `json:"contained,omitempty"` } // A UserObservation represents the observed state of a MSSQL user. diff --git a/apis/namespaced/mssql/v1alpha1/zz_generated.deepcopy.go b/apis/namespaced/mssql/v1alpha1/zz_generated.deepcopy.go index 85d06f85..024f24b4 100644 --- a/apis/namespaced/mssql/v1alpha1/zz_generated.deepcopy.go +++ b/apis/namespaced/mssql/v1alpha1/zz_generated.deepcopy.go @@ -654,6 +654,11 @@ func (in *UserParameters) DeepCopyInto(out *UserParameters) { *out = new(v1.NamespacedSelector) (*in).DeepCopyInto(*out) } + if in.Contained != nil { + in, out := &in.Contained, &out.Contained + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UserParameters. diff --git a/package/crds/mssql.sql.crossplane.io_users.yaml b/package/crds/mssql.sql.crossplane.io_users.yaml index 065ab745..13f05de9 100644 --- a/package/crds/mssql.sql.crossplane.io_users.yaml +++ b/package/crds/mssql.sql.crossplane.io_users.yaml @@ -71,6 +71,12 @@ spec: description: UserParameters define the desired state of a MSSQL user instance. properties: + contained: + description: |- + Contained specifies whether to create a contained database user (without server-level login). + When true, the user will be created directly in the specified database using CREATE USER WITH PASSWORD. + When false (default), a server-level LOGIN will be created first, then a database user mapped to that login. + type: boolean database: description: Database allows you to specify the name of the Database the USER is created for. @@ -254,6 +260,13 @@ spec: - namespace type: object type: object + x-kubernetes-validations: + - message: contained users cannot specify loginDatabase, loginDatabaseRef, + or loginDatabaseSelector + rule: '!(has(self.contained) && self.contained == true && (has(self.loginDatabase) + || has(self.loginDatabaseRef) || has(self.loginDatabaseSelector)))' + - message: contained field is immutable after creation + rule: '!has(oldSelf.contained) || self.contained == oldSelf.contained' managementPolicies: default: - '*' diff --git a/package/crds/mssql.sql.m.crossplane.io_users.yaml b/package/crds/mssql.sql.m.crossplane.io_users.yaml index e9d06b04..72b2c462 100644 --- a/package/crds/mssql.sql.m.crossplane.io_users.yaml +++ b/package/crds/mssql.sql.m.crossplane.io_users.yaml @@ -53,6 +53,12 @@ spec: description: UserParameters define the desired state of a MSSQL user instance. properties: + contained: + description: |- + Contained specifies whether to create a contained database user (without server-level login). + When true, the user will be created directly in the specified database using CREATE USER WITH PASSWORD. + When false (default), a server-level LOGIN will be created first, then a database user mapped to that login. + type: boolean database: description: Database allows you to specify the name of the Database the USER is created for. @@ -243,6 +249,13 @@ spec: - name type: object type: object + x-kubernetes-validations: + - message: contained users cannot specify loginDatabase, loginDatabaseRef, + or loginDatabaseSelector + rule: '!(has(self.contained) && self.contained == true && (has(self.loginDatabase) + || has(self.loginDatabaseRef) || has(self.loginDatabaseSelector)))' + - message: contained field is immutable after creation + rule: '!has(oldSelf.contained) || self.contained == oldSelf.contained' managementPolicies: default: - '*' diff --git a/pkg/controller/cluster/mssql/user/reconciler.go b/pkg/controller/cluster/mssql/user/reconciler.go index 2d96d1ca..08a2ebcb 100644 --- a/pkg/controller/cluster/mssql/user/reconciler.go +++ b/pkg/controller/cluster/mssql/user/reconciler.go @@ -185,18 +185,30 @@ func (c *external) Create(ctx context.Context, mg *v1alpha1.User) (managed.Exter } } - loginQuery := fmt.Sprintf("CREATE LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: loginQuery, - }); err != nil { - return managed.ExternalCreation{}, errors.Wrapf(err, errCreateLogin, meta.GetExternalName(mg)) - } + // Check if this should be a contained database user + if mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained { + // Create contained database user directly without LOGIN + userQuery := fmt.Sprintf("CREATE USER %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: userQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + } + } else { + // Create traditional LOGIN + USER approach + loginQuery := fmt.Sprintf("CREATE LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: loginQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateLogin, meta.GetExternalName(mg)) + } - userQuery := fmt.Sprintf("CREATE USER %s FOR LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteIdentifier(meta.GetExternalName(mg))) - if err := c.userDB.Exec(ctx, xsql.Query{ - String: userQuery, - }); err != nil { - return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + userQuery := fmt.Sprintf("CREATE USER %s FOR LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteIdentifier(meta.GetExternalName(mg))) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: userQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + } } return managed.ExternalCreation{ @@ -211,11 +223,22 @@ func (c *external) Update(ctx context.Context, mg *v1alpha1.User) (managed.Exter } if changed { - query := fmt.Sprintf("ALTER LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: query, - }); err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + if mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained { + // For contained users, use ALTER USER syntax + query := fmt.Sprintf("ALTER USER %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: query, + }); err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + } + } else { + // For traditional users, use ALTER LOGIN syntax + query := fmt.Sprintf("ALTER LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: query, + }); err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + } } return managed.ExternalUpdate{ @@ -229,25 +252,34 @@ func (c *external) Disconnect(ctx context.Context) error { return nil } -func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.ExternalDelete, error) { - query := fmt.Sprintf("SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = %s", mssql.QuoteValue(meta.GetExternalName(mg))) +func (c *external) killLoginSessions(ctx context.Context, loginName string) error { + query := fmt.Sprintf("SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = %s", mssql.QuoteValue(loginName)) rows, err := c.userDB.Query(ctx, xsql.Query{String: query}) if err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return errors.Wrap(err, errCannotGetLogins) } defer rows.Close() //nolint:errcheck for rows.Next() { var sessionID int if err := rows.Scan(&sessionID); err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return errors.Wrap(err, errCannotGetLogins) } if err := c.userDB.Exec(ctx, xsql.Query{String: fmt.Sprintf("KILL %d", sessionID)}); err != nil { - return managed.ExternalDelete{}, errors.Wrapf(err, errCannotKillLoginSession, sessionID, meta.GetExternalName(mg)) + return errors.Wrapf(err, errCannotKillLoginSession, sessionID, loginName) } } - if err := rows.Err(); err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return rows.Err() +} + +func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.ExternalDelete, error) { + isContained := mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained + + // Only kill sessions for traditional users with logins, not contained users + if !isContained { + if err := c.killLoginSessions(ctx, meta.GetExternalName(mg)); err != nil { + return managed.ExternalDelete{}, err + } } if err := c.userDB.Exec(ctx, xsql.Query{ @@ -256,10 +288,13 @@ func (c *external) Delete(ctx context.Context, mg *v1alpha1.User) (managed.Exter return managed.ExternalDelete{}, errors.Wrapf(err, errDropUser, meta.GetExternalName(mg)) } - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: fmt.Sprintf("DROP LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg))), - }); err != nil { - return managed.ExternalDelete{}, errors.Wrapf(err, errDropLogin, meta.GetExternalName(mg)) + // Only drop LOGIN if this is not a contained user + if !isContained { + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: fmt.Sprintf("DROP LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg))), + }); err != nil { + return managed.ExternalDelete{}, errors.Wrapf(err, errDropLogin, meta.GetExternalName(mg)) + } } return managed.ExternalDelete{}, nil diff --git a/pkg/controller/namespaced/mssql/user/reconciler.go b/pkg/controller/namespaced/mssql/user/reconciler.go index 854ad1e7..ea4888b4 100644 --- a/pkg/controller/namespaced/mssql/user/reconciler.go +++ b/pkg/controller/namespaced/mssql/user/reconciler.go @@ -168,18 +168,31 @@ func (c *external) Create(ctx context.Context, mg *namespacedv1alpha1.User) (man } } - loginQuery := fmt.Sprintf("CREATE LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: loginQuery, - }); err != nil { - return managed.ExternalCreation{}, errors.Wrapf(err, errCreateLogin, meta.GetExternalName(mg)) - } + // Check if this should be a contained database user + if mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained { + // Create contained database user directly without LOGIN + dbName := ptr.Deref(mg.Spec.ForProvider.Database, "") + userQuery := fmt.Sprintf("USE %s; CREATE USER %s WITH PASSWORD = %s", mssql.QuoteIdentifier(dbName), mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: userQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + } + } else { + // Create traditional LOGIN + USER approach + loginQuery := fmt.Sprintf("CREATE LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: loginQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateLogin, meta.GetExternalName(mg)) + } - userQuery := fmt.Sprintf("CREATE USER %s FOR LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteIdentifier(meta.GetExternalName(mg))) - if err := c.userDB.Exec(ctx, xsql.Query{ - String: userQuery, - }); err != nil { - return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + userQuery := fmt.Sprintf("CREATE USER %s FOR LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteIdentifier(meta.GetExternalName(mg))) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: userQuery, + }); err != nil { + return managed.ExternalCreation{}, errors.Wrapf(err, errCreateUser, meta.GetExternalName(mg)) + } } return managed.ExternalCreation{ @@ -194,11 +207,23 @@ func (c *external) Update(ctx context.Context, mg *namespacedv1alpha1.User) (man } if changed { - query := fmt.Sprintf("ALTER LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: query, - }); err != nil { - return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + if mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained { + // For contained users, use ALTER USER syntax with explicit USE statement + dbName := ptr.Deref(mg.Spec.ForProvider.Database, "") + query := fmt.Sprintf("USE %s; ALTER USER %s WITH PASSWORD = %s", mssql.QuoteIdentifier(dbName), mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.userDB.Exec(ctx, xsql.Query{ + String: query, + }); err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + } + } else { + // For traditional users, use ALTER LOGIN syntax + query := fmt.Sprintf("ALTER LOGIN %s WITH PASSWORD=%s", mssql.QuoteIdentifier(meta.GetExternalName(mg)), mssql.QuoteValue(pw)) + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: query, + }); err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errUpdateUser) + } } return managed.ExternalUpdate{ @@ -212,25 +237,34 @@ func (c *external) Disconnect(ctx context.Context) error { return nil } -func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalDelete, error) { - query := fmt.Sprintf("SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = %s", mssql.QuoteValue(meta.GetExternalName(mg))) +func (c *external) killLoginSessions(ctx context.Context, loginName string) error { + query := fmt.Sprintf("SELECT session_id FROM sys.dm_exec_sessions WHERE login_name = %s", mssql.QuoteValue(loginName)) rows, err := c.userDB.Query(ctx, xsql.Query{String: query}) if err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return errors.Wrap(err, errCannotGetLogins) } defer rows.Close() //nolint:errcheck for rows.Next() { var sessionID int if err := rows.Scan(&sessionID); err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return errors.Wrap(err, errCannotGetLogins) } if err := c.userDB.Exec(ctx, xsql.Query{String: fmt.Sprintf("KILL %d", sessionID)}); err != nil { - return managed.ExternalDelete{}, errors.Wrapf(err, errCannotKillLoginSession, sessionID, meta.GetExternalName(mg)) + return errors.Wrapf(err, errCannotKillLoginSession, sessionID, loginName) } } - if err := rows.Err(); err != nil { - return managed.ExternalDelete{}, errors.Wrap(err, errCannotGetLogins) + return rows.Err() +} + +func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (managed.ExternalDelete, error) { + isContained := mg.Spec.ForProvider.Contained != nil && *mg.Spec.ForProvider.Contained + + // Only kill sessions for traditional users with logins, not contained users + if !isContained { + if err := c.killLoginSessions(ctx, meta.GetExternalName(mg)); err != nil { + return managed.ExternalDelete{}, err + } } if err := c.userDB.Exec(ctx, xsql.Query{ @@ -239,10 +273,13 @@ func (c *external) Delete(ctx context.Context, mg *namespacedv1alpha1.User) (man return managed.ExternalDelete{}, errors.Wrapf(err, errDropUser, meta.GetExternalName(mg)) } - if err := c.loginDB.Exec(ctx, xsql.Query{ - String: fmt.Sprintf("DROP LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg))), - }); err != nil { - return managed.ExternalDelete{}, errors.Wrapf(err, errDropLogin, meta.GetExternalName(mg)) + // Only drop LOGIN if this is not a contained user + if !isContained { + if err := c.loginDB.Exec(ctx, xsql.Query{ + String: fmt.Sprintf("DROP LOGIN %s", mssql.QuoteIdentifier(meta.GetExternalName(mg))), + }); err != nil { + return managed.ExternalDelete{}, errors.Wrapf(err, errDropLogin, meta.GetExternalName(mg)) + } } return managed.ExternalDelete{}, nil