From f575240eec374aec11d688a982a5431fca8f4be9 Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 8 Apr 2026 21:27:37 +0800 Subject: [PATCH 1/2] feat(security): add PasswordPolicy.ValidatePassword method Adds password validation against project password policy settings (minimum length, require digit, mixed case, symbol). Nil/zero policies accept any password for backward compatibility. --- sdk/security/security.go | 59 +++++++++++++++++++++++++++++++++++ sdk/security/security_test.go | 49 +++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 sdk/security/security_test.go diff --git a/sdk/security/security.go b/sdk/security/security.go index 65a1fcc5..67807f5f 100644 --- a/sdk/security/security.go +++ b/sdk/security/security.go @@ -4,6 +4,9 @@ package security import ( + "fmt" + "unicode" + "github.com/mendixlabs/mxcli/model" ) @@ -55,6 +58,62 @@ type PasswordPolicy struct { RequireSymbol bool `json:"requireSymbol"` } +// ValidatePassword checks a password against the policy. +// Returns nil if the password is compliant, or an error describing the first violation. +// A nil policy accepts any password. +func (p *PasswordPolicy) ValidatePassword(password string) error { + if p == nil { + return nil + } + if p.MinimumLength > 0 && len(password) < p.MinimumLength { + return fmt.Errorf("password must be at least %d characters (got %d)", p.MinimumLength, len(password)) + } + if p.RequireDigit && !containsDigit(password) { + return fmt.Errorf("password must contain at least one digit") + } + if p.RequireMixedCase && !containsMixedCase(password) { + return fmt.Errorf("password must contain both uppercase and lowercase letters") + } + if p.RequireSymbol && !containsSymbol(password) { + return fmt.Errorf("password must contain at least one symbol") + } + return nil +} + +func containsDigit(s string) bool { + for _, r := range s { + if unicode.IsDigit(r) { + return true + } + } + return false +} + +func containsMixedCase(s string) bool { + hasUpper, hasLower := false, false + for _, r := range s { + if unicode.IsUpper(r) { + hasUpper = true + } + if unicode.IsLower(r) { + hasLower = true + } + if hasUpper && hasLower { + return true + } + } + return false +} + +func containsSymbol(s string) bool { + for _, r := range s { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + return true + } + } + return false +} + // ModuleSecurity represents the security configuration for a module. type ModuleSecurity struct { model.BaseElement diff --git a/sdk/security/security_test.go b/sdk/security/security_test.go new file mode 100644 index 00000000..4be3e265 --- /dev/null +++ b/sdk/security/security_test.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 + +package security + +import "testing" + +func TestPasswordPolicy_ValidatePassword(t *testing.T) { + policy := &PasswordPolicy{ + MinimumLength: 8, + RequireDigit: true, + RequireMixedCase: true, + RequireSymbol: true, + } + + tests := []struct { + name string + password string + wantErr bool + }{ + {"valid", "Passw0rd!", false}, + {"too short", "Pa0!", true}, + {"no digit", "Password!", true}, + {"no mixed case", "passw0rd!", true}, + {"no symbol", "Passw0rdd", true}, + {"empty", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := policy.ValidatePassword(tt.password) + if (err != nil) != tt.wantErr { + t.Errorf("ValidatePassword(%q) error = %v, wantErr %v", tt.password, err, tt.wantErr) + } + }) + } +} + +func TestPasswordPolicy_ValidatePassword_NilPolicy(t *testing.T) { + var policy *PasswordPolicy + if err := policy.ValidatePassword("anything"); err != nil { + t.Errorf("nil policy should accept any password, got: %v", err) + } +} + +func TestPasswordPolicy_ValidatePassword_ZeroPolicy(t *testing.T) { + policy := &PasswordPolicy{} + if err := policy.ValidatePassword("x"); err != nil { + t.Errorf("zero policy should accept any password, got: %v", err) + } +} From 25c9fccdfeb54f0e056cdf64e7bbc5dac354b3aa Mon Sep 17 00:00:00 2001 From: engalar Date: Wed, 8 Apr 2026 21:34:48 +0800 Subject: [PATCH 2/2] fix(security): validate demo user password against project policy CREATE DEMO USER now checks the password against the project's PasswordPolicy before writing to MPR. Previously, non-compliant passwords were accepted silently but the Mendix runtime would skip creating the user, leading to confusing "unknown user" login errors. Closes mendixlabs/mxcli#137 --- mdl/executor/cmd_security_write.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mdl/executor/cmd_security_write.go b/mdl/executor/cmd_security_write.go index a152ba88..dd04850f 100644 --- a/mdl/executor/cmd_security_write.go +++ b/mdl/executor/cmd_security_write.go @@ -947,6 +947,11 @@ func (e *Executor) execCreateDemoUser(s *ast.CreateDemoUserStmt) error { return fmt.Errorf("failed to read project security: %w", err) } + // Validate password against project password policy + if err := ps.PasswordPolicy.ValidatePassword(s.Password); err != nil { + return fmt.Errorf("password policy violation for demo user '%s': %w\nhint: check your project's password policy with SHOW PROJECT SECURITY", s.UserName, err) + } + // Check if user already exists for _, du := range ps.DemoUsers { if du.UserName == s.UserName {