66 "fmt"
77 "log"
88 "regexp"
9+ "strconv"
910 "strings"
1011
1112 "github.com/hashicorp/go-cty/cty"
@@ -44,6 +45,24 @@ func resourceUser() *schema.Resource {
4445 StateContext : ImportUser ,
4546 },
4647
48+ CustomizeDiff : func (ctx context.Context , d * schema.ResourceDiff , meta interface {}) error {
49+ // Validate max_user_connections is not set on TiDB
50+ if _ , ok := d .GetOk ("max_user_connections" ); ok {
51+ if err := checkMaxUserConnectionsSupport (ctx , meta ); err != nil {
52+ return err
53+ }
54+ }
55+
56+ // Validate max_statement_time is not set on non-MariaDB
57+ if _ , ok := d .GetOk ("max_statement_time" ); ok {
58+ if err := checkMaxStatementTimeSupport (ctx , meta ); err != nil {
59+ return err
60+ }
61+ }
62+
63+ return nil
64+ },
65+
4766 Schema : map [string ]* schema.Schema {
4867 "user" : {
4968 Type : schema .TypeString ,
@@ -154,6 +173,20 @@ func resourceUser() *schema.Resource {
154173 Optional : true ,
155174 Default : false ,
156175 },
176+
177+ "max_user_connections" : {
178+ Type : schema .TypeInt ,
179+ Optional : true ,
180+ ValidateFunc : validation .IntAtLeast (0 ),
181+ Description : "Maximum number of simultaneous connections for the user (0 = unlimited). Supported on MySQL 5.0+ and MariaDB." ,
182+ },
183+
184+ "max_statement_time" : {
185+ Type : schema .TypeFloat ,
186+ Optional : true ,
187+ ValidateFunc : validation .FloatAtLeast (0 ),
188+ Description : "Maximum execution time for statements in seconds (0 = unlimited). Supports fractional values (e.g., 0.01 for 10ms, 30.5 for 30.5s). Only supported on MariaDB 10.1.1+, not MySQL." ,
189+ },
157190 },
158191 }
159192}
@@ -174,6 +207,50 @@ func checkDiscardOldPasswordSupport(ctx context.Context, meta interface{}) error
174207 return nil
175208}
176209
210+ func checkMaxUserConnectionsSupport (ctx context.Context , meta interface {}) error {
211+ db , err := getDatabaseFromMeta (ctx , meta )
212+ if err != nil {
213+ return err
214+ }
215+
216+ isTiDB , _ , _ , err := serverTiDB (db )
217+ if err != nil {
218+ return err
219+ }
220+
221+ if isTiDB {
222+ return errors .New ("MAX_USER_CONNECTIONS is not supported on TiDB" )
223+ }
224+
225+ return nil
226+ }
227+
228+ func checkMaxStatementTimeSupport (ctx context.Context , meta interface {}) error {
229+ db , err := getDatabaseFromMeta (ctx , meta )
230+ if err != nil {
231+ return err
232+ }
233+
234+ isMariaDB , err := serverMariaDB (db )
235+ if err != nil {
236+ return err
237+ }
238+
239+ if ! isMariaDB {
240+ return errors .New ("MAX_STATEMENT_TIME is only supported on MariaDB 10.1.1+, not MySQL" )
241+ }
242+
243+ // Check MariaDB version
244+ currentVer := getVersionFromMeta (ctx , meta )
245+ minVer , _ := version .NewVersion ("10.1.1" )
246+
247+ if currentVer .LessThan (minVer ) {
248+ return fmt .Errorf ("MAX_STATEMENT_TIME requires MariaDB 10.1.1 or newer (current version: %s)" , currentVer .String ())
249+ }
250+
251+ return nil
252+ }
253+
177254func CreateUser (ctx context.Context , d * schema.ResourceData , meta interface {}) diag.Diagnostics {
178255 db , err := getDatabaseFromMeta (ctx , meta )
179256 if err != nil {
@@ -296,6 +373,35 @@ func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
296373 }
297374 }
298375
376+ // Add resource limits if specified
377+ // Note: MySQL 5.6 does NOT support CREATE USER ... WITH for resource limits
378+ // MySQL 5.7.6+ supports both CREATE USER ... WITH and ALTER USER ... WITH
379+ // For MySQL < 5.7.6, we need to use GRANT USAGE after CREATE USER
380+ var resourceLimits []string
381+ if createObj != "AADUSER" {
382+ // MAX_USER_CONNECTIONS - supported on MySQL and MariaDB, but not TiDB
383+ if maxConn , ok := d .GetOk ("max_user_connections" ); ok {
384+ if err := checkMaxUserConnectionsSupport (ctx , meta ); err != nil {
385+ return diag .FromErr (err )
386+ }
387+ resourceLimits = append (resourceLimits , fmt .Sprintf ("MAX_USER_CONNECTIONS %d" , maxConn .(int )))
388+ }
389+
390+ // MAX_STATEMENT_TIME - MariaDB only
391+ if maxStmt , ok := d .GetOk ("max_statement_time" ); ok {
392+ if err := checkMaxStatementTimeSupport (ctx , meta ); err != nil {
393+ return diag .FromErr (err )
394+ }
395+ resourceLimits = append (resourceLimits , fmt .Sprintf ("MAX_STATEMENT_TIME %f" , maxStmt .(float64 )))
396+ }
397+
398+ // MySQL 5.7.6+ supports CREATE USER ... WITH for resource limits
399+ createUserWithVersion , _ := version .NewVersion ("5.7.6" )
400+ if len (resourceLimits ) > 0 && getVersionFromMeta (ctx , meta ).GreaterThanOrEqual (createUserWithVersion ) {
401+ stmtSQL += " WITH " + strings .Join (resourceLimits , " " )
402+ }
403+ }
404+
299405 // Log statement with sensitive values redacted
300406 logStmt := stmtSQL
301407 if password != "" {
@@ -311,6 +417,20 @@ func CreateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
311417 return diag .Errorf ("failed executing SQL: %v" , err )
312418 }
313419
420+ // For MySQL < 5.7.6, use GRANT USAGE to set resource limits after CREATE USER
421+ createUserWithVersion , _ := version .NewVersion ("5.7.6" )
422+ if createObj != "AADUSER" && len (resourceLimits ) > 0 && getVersionFromMeta (ctx , meta ).LessThan (createUserWithVersion ) {
423+ grantStmtSQL := fmt .Sprintf ("GRANT USAGE ON *.* TO %s WITH %s" ,
424+ formatUserIdentifier (user , host ),
425+ strings .Join (resourceLimits , " " ))
426+
427+ log .Println ("[DEBUG] Executing statement:" , grantStmtSQL )
428+ _ , err = db .ExecContext (ctx , grantStmtSQL )
429+ if err != nil {
430+ return diag .Errorf ("failed setting user resource limits: %v" , err )
431+ }
432+ }
433+
314434 userId := fmt .Sprintf ("%s@%s" , user , host )
315435 d .SetId (userId )
316436
@@ -453,9 +573,104 @@ func UpdateUser(ctx context.Context, d *schema.ResourceData, meta interface{}) d
453573 }
454574 }
455575
576+ // Handle resource limits changes (Option B: field removal resets to 0)
577+ // MySQL 5.6: ALTER USER only supports PASSWORD EXPIRE, use GRANT USAGE for resource limits
578+ // MySQL 5.7.6+: ALTER USER supports WITH clause for resource limits
579+ if d .HasChange ("max_user_connections" ) || d .HasChange ("max_statement_time" ) {
580+ var resourceLimits []string
581+
582+ // Handle MAX_USER_CONNECTIONS
583+ if maxConn , ok := d .GetOk ("max_user_connections" ); ok {
584+ // Field is present in config, validate and set the value
585+ if err := checkMaxUserConnectionsSupport (ctx , meta ); err != nil {
586+ return diag .FromErr (err )
587+ }
588+ resourceLimits = append (resourceLimits , fmt .Sprintf ("MAX_USER_CONNECTIONS %d" , maxConn .(int )))
589+ } else if d .HasChange ("max_user_connections" ) {
590+ // Field was removed from config, reset to 0 (unlimited)
591+ // Only reset if we're not on TiDB (which doesn't support this feature)
592+ isTiDBVal , _ , _ , err := serverTiDB (db )
593+ if err != nil {
594+ return diag .FromErr (err )
595+ }
596+ if ! isTiDBVal {
597+ resourceLimits = append (resourceLimits , "MAX_USER_CONNECTIONS 0" )
598+ } else {
599+ return diag .Errorf ("cannot reset max_user_connections on TiDB: MAX_USER_CONNECTIONS is not supported on TiDB" )
600+ }
601+ }
602+
603+ // Handle MAX_STATEMENT_TIME (MariaDB only)
604+ if maxStmt , ok := d .GetOk ("max_statement_time" ); ok {
605+ // Field is present in config, validate and set the value
606+ if err := checkMaxStatementTimeSupport (ctx , meta ); err != nil {
607+ return diag .FromErr (err )
608+ }
609+ resourceLimits = append (resourceLimits , fmt .Sprintf ("MAX_STATEMENT_TIME %f" , maxStmt .(float64 )))
610+ } else if d .HasChange ("max_statement_time" ) {
611+ // Field was removed from config, reset to 0 (unlimited)
612+ // Only reset if we're on MariaDB (no need to check version, just database type)
613+ isMariaDBVal , err := serverMariaDB (db )
614+ if err != nil {
615+ return diag .FromErr (err )
616+ }
617+ if isMariaDBVal {
618+ resourceLimits = append (resourceLimits , "MAX_STATEMENT_TIME 0" )
619+ }
620+ }
621+
622+ if len (resourceLimits ) > 0 {
623+ var stmtSQL string
624+
625+ // MySQL versions before 5.7.6 don't support ALTER USER with WITH clause
626+ // Use GRANT USAGE instead for older versions
627+ alterUserVersion , _ := version .NewVersion ("5.7.6" )
628+ if getVersionFromMeta (ctx , meta ).LessThan (alterUserVersion ) {
629+ // MySQL 5.6 and earlier: use GRANT USAGE
630+ stmtSQL = fmt .Sprintf ("GRANT USAGE ON *.* TO %s WITH %s" ,
631+ formatUserIdentifier (d .Get ("user" ).(string ), d .Get ("host" ).(string )),
632+ strings .Join (resourceLimits , " " ))
633+ } else {
634+ // MySQL 5.7.6+: use ALTER USER
635+ stmtSQL = fmt .Sprintf ("ALTER USER %s WITH %s" ,
636+ formatUserIdentifier (d .Get ("user" ).(string ), d .Get ("host" ).(string )),
637+ strings .Join (resourceLimits , " " ))
638+ }
639+
640+ log .Println ("[DEBUG] Executing query:" , stmtSQL )
641+ _ , err := db .ExecContext (ctx , stmtSQL )
642+ if err != nil {
643+ return diag .Errorf ("failed setting user resource limits: %v" , err )
644+ }
645+ }
646+ }
647+
456648 return nil
457649}
458650
651+ // parseWithClauseSetting extracts and sets a resource limit from the WITH clause
652+ func parseWithClauseSetting (d * schema.ResourceData , withClause , fieldName , settingName string , parseAsFloat bool ) {
653+ // Only set if the field is currently being managed (Option B behavior)
654+ if _ , ok := d .GetOk (fieldName ); ! ok {
655+ return
656+ }
657+
658+ pattern := fmt .Sprintf (`%s\s+([\d.]+)` , settingName )
659+ re := regexp .MustCompile (pattern )
660+
661+ if match := re .FindStringSubmatch (withClause ); len (match ) > 1 {
662+ if parseAsFloat {
663+ if value , err := strconv .ParseFloat (match [1 ], 64 ); err == nil {
664+ d .Set (fieldName , value )
665+ }
666+ } else {
667+ if value , err := strconv .Atoi (match [1 ]); err == nil {
668+ d .Set (fieldName , value )
669+ }
670+ }
671+ }
672+ }
673+
459674func ReadUser (ctx context.Context , d * schema.ResourceData , meta interface {}) diag.Diagnostics {
460675 db , err := getDatabaseFromMeta (ctx , meta )
461676 if err != nil {
@@ -545,13 +760,36 @@ func ReadUser(ctx context.Context, d *schema.ResourceData, meta interface{}) dia
545760 d .Set ("auth_string_hex" , "" )
546761 }
547762 }
763+
764+ // Parse resource limits from WITH clause if present
765+ // Examples of WITH clause in CREATE USER:
766+ // CREATE USER 'user'@'host' ... WITH MAX_USER_CONNECTIONS 10
767+ // CREATE USER 'user'@'host' ... WITH MAX_STATEMENT_TIME 30.5 (MariaDB only)
768+ // CREATE USER 'user'@'host' ... WITH MAX_USER_CONNECTIONS 5 MAX_STATEMENT_TIME 60.0
769+ withRe := regexp .MustCompile (`WITH\s+(.*)$` )
770+ if withMatch := withRe .FindStringSubmatch (createUserStmt ); len (withMatch ) > 1 {
771+ withClause := withMatch [1 ]
772+
773+ parseWithClauseSetting (d , withClause , "max_user_connections" , "MAX_USER_CONNECTIONS" , false )
774+ parseWithClauseSetting (d , withClause , "max_statement_time" , "MAX_STATEMENT_TIME" , true )
775+ }
776+
548777 return nil
549778 }
550779
551780 // Try 2 - just whether the user is there.
552781 re2 := regexp .MustCompile ("^CREATE USER" )
553782 if m := re2 .FindStringSubmatch (createUserStmt ); m != nil {
554783 // Ok, we have at least something - it's probably in MariaDB.
784+ // Parse resource limits from WITH clause if present (MariaDB format)
785+ withRe := regexp .MustCompile (`WITH\s+(.*)$` )
786+ if withMatch := withRe .FindStringSubmatch (createUserStmt ); len (withMatch ) > 1 {
787+ withClause := withMatch [1 ]
788+
789+ parseWithClauseSetting (d , withClause , "max_user_connections" , "MAX_USER_CONNECTIONS" , false )
790+ parseWithClauseSetting (d , withClause , "max_statement_time" , "MAX_STATEMENT_TIME" , true )
791+ }
792+
555793 return nil
556794 }
557795 return diag .Errorf ("Create user couldn't be parsed - it is %s" , createUserStmt )
0 commit comments