Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"area": "mdl/executor", "date": "2026-10-08", "symptom": "MDL could not write the two values lint gained readers for in #1269: `/** Sales data */ create or modify module MyFirstModule;` warned \"the doc comment before `create or modify module MyFirstModule` is lost … that statement cannot store documentation, so the comment is ignored [MDL089]\", and `alter app security (AdminUserName: 'appadmin');` failed \"alter app security has no property \\\"AdminUserName\\\"\". Every MDL-built module had empty documentation and the admin was always MxAdmin, so the lint rules over them had no negative case. Issue mendixlabs/mxcli#1314", "cause": "Read-only by design at the time: #1269 added DomainModel.Documentation to the semantic model as read-only, and no backend method wrote it or Security$ProjectSecurity.AdminUserName (gen's SetDocumentation/SetAdminUserName were never called). CreateModuleStmt had no Documentation field, so storesDocumentation() reported its doc comment as detached.", "file": "mdl/visitor/visitor_module.go, mdl/visitor/visitor_security.go (appSecurityKeys), mdl/executor/cmd_modules.go (execCreateModule, setModuleDocumentation, describeModule), mdl/executor/cmd_security_write.go, mdl/backend/modelsdk/domainmodel_alter.go (SetDomainModelDocumentation), mdl/backend/modelsdk/security_write.go (SetProjectAdminUserName)", "fix": "The doc comment on `create [or modify] module` is the domain model's documentation (a Projects$Module has none): new module -> set; existing + `or modify` -> set (`/** */` clears, no comment preserves); plain create of an existing module stays a no-op and says documentation is unchanged. describe module prints it as that comment. `AdminUserName: '<name>'` joins alter app security's property list (string only, non-empty; password stays out per #624).", "insight": "**MDL089 is a capability map, not just a warning**: storesDocumentation() decides by reflecting on the AST for a Documentation field, so adding the field silences MDL089 by itself — the visitor test must assert the field is filled, not only that no detached comment is reported, or a half-wired change passes. When the obvious owner has no property (Projects$Module), look one unit down: the module's documentation is DomainModels$DomainModel.Documentation, which is what lint and Studio Pro's canvas pane show. Verified on a fresh 11.12.5 app: exec, describe round-trip, `select … from catalog.modules`, and `mx check` 0 errors against an untouched control. Repro `mdl-examples/bug-tests/1314-module-documentation-and-admin-user-name.mdl`.", "refs": ["mdl-examples/bug-tests/1314-module-documentation-and-admin-user-name.mdl", "mdl/executor/cmd_module_documentation_test.go", "mdl/visitor/visitor_module_documentation_test.go", "mdl/backend/modelsdk/issue1314_documentation_admin_test.go"]}
4 changes: 4 additions & 0 deletions .claude/skills/mendix/manage-security/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ alter app security ( SecurityLevel: production );
-- Enable/disable demo users
alter app security ( EnableDemoUsers: true );
alter app security ( EnableDemoUsers: false );

-- Rename the built-in administrator (default MxAdmin). The password is not
-- settable from MDL.
alter app security ( AdminUserName: 'appadmin' );
```

### Guest (Anonymous) Access
Expand Down
13 changes: 8 additions & 5 deletions cmd/mxcli/syntax/features_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,25 +142,28 @@ func init() {

Register(SyntaxFeature{
Path: "security.project-security",
Summary: "Set project security level, strict mode, demo user and guest access toggles",
Summary: "Set project security level, strict mode, demo user and guest access toggles, admin user name",
Keywords: []string{
"project security", "app security", "alter app security", "security level", "prototype",
"production", "off", "strict mode", "SEC005",
"production", "off", "strict mode", "SEC005", "admin user", "AdminUserName", "MxAdmin",
},
Syntax: "ALTER APP SECURITY (\n" +
" [SecurityLevel: OFF|PROTOTYPE|PRODUCTION,]\n" +
" [EnableDemoUsers: TRUE|FALSE,]\n" +
" [EnableGuestAccess: TRUE|FALSE,]\n" +
" [GuestUserRole: <UserRole>,]\n" +
" [StrictMode: TRUE|FALSE] -- clears lint rule SEC005\n" +
" [StrictMode: TRUE|FALSE,] -- clears lint rule SEC005\n" +
" [AdminUserName: '<name>'] -- the built-in administrator (default MxAdmin)\n" +
");\n\n" +
"-- Set any subset of the properties, in create's ( Key: value ) list. The\n" +
"-- clause forms (LEVEL …, DEMO USERS ON|OFF, GUEST ACCESS ON [ROLE r]|OFF,\n" +
"-- STRICT MODE ON|OFF) still run and warn MDL-DEPR133.",
"-- STRICT MODE ON|OFF) still run and warn MDL-DEPR133. The administrator's\n" +
"-- password is not settable from MDL.",
Example: "mdl 1;\n" +
"ALTER APP SECURITY ( SecurityLevel: PRODUCTION );\n" +
"ALTER APP SECURITY ( EnableDemoUsers: FALSE );\n" +
"ALTER APP SECURITY ( StrictMode: TRUE );",
"ALTER APP SECURITY ( StrictMode: TRUE );\n" +
"ALTER APP SECURITY ( AdminUserName: 'appadmin' );",
SeeAlso: []string{"security.demo-user", "security.guest-access"},
})

Expand Down
1 change: 1 addition & 0 deletions docs-site/src/appendixes/quick-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a
| Set security level | `ALTER APP SECURITY ( SecurityLevel: OFF\|PROTOTYPE\|PRODUCTION );` | |
| Toggle demo users | `ALTER APP SECURITY ( EnableDemoUsers: TRUE\|FALSE );` | |
| Toggle guest access | `ALTER APP SECURITY ( EnableGuestAccess: TRUE, GuestUserRole: UserRole );` / `( EnableGuestAccess: FALSE )` | Anonymous users; role required (CE0133) |
| Rename admin user | `ALTER APP SECURITY ( AdminUserName: 'appadmin' );` | Default `MxAdmin`; the password is not settable from MDL |
| Create demo user | `CREATE DEMO USER 'name' ( Password: 'pass', [Entity: Module.Entity,] UserRoles: (UserRole, ...) );` | The clause form `PASSWORD … (…)` warns MDL-DEPR137 |
| Drop demo user | `DROP DEMO USER [IF EXISTS] 'name';` | `IF EXISTS` makes a cleanup script re-runnable |

Expand Down
2 changes: 2 additions & 0 deletions docs/01-project/MDL_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ create persistent entity Module.Photo (
| Show modules | `list modules;` | List all modules |
| Describe module | `describe module ModuleName;` | All contents (entities, microflows, pages, etc.) |
| Create module | `create module ModuleName;` | |
| Module documentation | `/** text */ create or modify module ModuleName;` | Stored as the module's **domain model** documentation (a module has none of its own) — what lint reads as `modules().domain_model_documentation`. `describe module` prints it the same way. No comment leaves it as stored; `/** */` clears it; a plain `create module` of an existing module changes nothing |
| Drop module | `drop module [if exists] ModuleName;` | |
| Rename module | `rename module OldName to NewName;` | Updates all qualified name references |

Expand Down Expand Up @@ -710,6 +711,7 @@ Nested folders use `/` separator: `'Parent/Child/Grandchild'`. Missing folders a
| Toggle demo users | `alter app security ( EnableDemoUsers: true\|false );` | Several keys may go in one list |
| Enable guest access | `alter app security ( EnableGuestAccess: true, GuestUserRole: UserRole );` | Anonymous users. The role is what visitors get — its entity access is the public surface. Mendix fails the build without one (CE0133), so `on` is refused unless a role is given or already stored. mxcli validates the role exists; Mendix does not |
| Disable guest access | `alter app security ( EnableGuestAccess: false );` | Keeps the stored role, so re-enabling needs no `GuestUserRole` |
| Rename admin user | `alter app security ( AdminUserName: 'appadmin' );` | The built-in administrator (default `MxAdmin`), read by lint as `project_security().admin_user_name`. Its password is not settable from MDL |
| Create demo user | `create demo user 'name' ( Password: 'pass', [Entity: Module.Entity,] UserRoles: (UserRole, ...) );` | The clause form `password … (…)` warns MDL-DEPR137 |
| Drop demo user | `drop demo user [if exists] 'name';` | `if exists` makes a cleanup script re-runnable |
| Update security | `update security [[in] Module];` | Re-syncs access rules with their domain model — Studio Pro's **Update security** button, headless. Repairs **CE0066** "Entity access is out of date", which a model authored elsewhere can carry (a module imported or updated outside Studio Pro). Not needed after mxcli's own writes: every write path reconciles as it writes. Writes nothing when the rules already match, and skips `System` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
mdl 1;
-- mendixlabs/mxcli#1314: since #1269 lint reads
-- modules().domain_model_documentation and project_security().admin_user_name,
-- and MDL could write neither, so the rules over them had no negative case in
-- an MDL-built app. Reported:
--
-- line 2: the doc comment before `create or modify module MyFirstModule` is lost
-- … that statement cannot store documentation, so the comment is ignored [MDL089]
-- line 2: alter app security has no property "AdminUserName"; …
--
-- A Mendix module has no documentation property; its domain model
-- (DomainModels$DomainModel.Documentation) does, and that is what the doc
-- comment on `create module` now sets. `describe module` prints it back as
-- that comment. A comment that is absent leaves the stored text alone; `/** */`
-- clears it. A plain `create module` of a module that exists stays a no-op.
--
-- The admin password stays out of MDL (mendixlabs/mxcli#624).
--
-- Verify:
--
-- mxcli exec 1314-module-documentation-and-admin-user-name.mdl -p app.mpr
-- mxcli -p app.mpr -c "describe module Bug1314Sales" -- prints the /** … */
-- mxcli -p app.mpr -c "show project security" -- Admin User: appadmin
-- mxcli docker check -p app.mpr
--
-- Then perturb: drop the doc comment and re-run — describe still prints it
-- (absent preserves); replace it with `/** */` — it is gone.

/** Sales data, per region */
create or modify module Bug1314Sales;

alter app security ( AdminUserName: 'appadmin' );
9 changes: 9 additions & 0 deletions mdl/ast/ast_enumeration.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ type EnumValue struct {
type CreateModuleStmt struct {
CreateGuard // `create … if not exists` (ako/mxcli#731)
Name string
// CreateOrModify is `create or modify module`: on a module that exists, the
// statement's documentation is applied rather than the statement being a
// no-op.
CreateOrModify bool
// Documentation is the doc comment, stored as the module's domain model
// documentation: a Mendix module has none of its own, its
// DomainModels$DomainModel does (mendixlabs/mxcli#1314).
Documentation string
DocumentationSet bool // see mendixlabs/mxcli#1018: absent preserves, empty clears
}

func (s *CreateModuleStmt) isStatement() {}
Expand Down
4 changes: 4 additions & 0 deletions mdl/ast/ast_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ type AlterProjectSecurityStmt struct {
// A pointer, so "the statement said nothing about it" is distinguishable
// from "the statement asked for off".
StrictModeEnabled *bool
// AdminUserName renames the built-in administrator account (MxAdmin by
// default). Empty means "keep what is stored". Its password is deliberately
// not settable from MDL (mendixlabs/mxcli#624).
AdminUserName string
}

func (s *AlterProjectSecurityStmt) isStatement() {}
Expand Down
6 changes: 6 additions & 0 deletions mdl/backend/domainmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ type DomainModelBackend interface {
// deleted in the first place.
SetDomainModelAnnotations(domainModelID model.ID, annotations []*domainmodel.Annotation) error

// SetDomainModelDocumentation sets the domain model's own documentation —
// the only documentation a module has (mendixlabs/mxcli#1314). Separate
// from UpdateDomainModel for the same reason as annotations: that one
// carries the stored value through untouched.
SetDomainModelDocumentation(domainModelID model.ID, documentation string) error

// Entities
CreateEntity(domainModelID model.ID, entity *domainmodel.Entity) error
UpdateEntity(domainModelID model.ID, entity *domainmodel.Entity) error
Expand Down
10 changes: 10 additions & 0 deletions mdl/backend/mcp/unsupported_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions mdl/backend/mock/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type MockBackend struct {
GetDomainModelByIDFunc func(id model.ID) (*domainmodel.DomainModel, error)
UpdateDomainModelFunc func(dm *domainmodel.DomainModel) error
SetDomainModelAnnotationsFunc func(domainModelID model.ID, annotations []*domainmodel.Annotation) error
SetDomainModelDocumentationFunc func(domainModelID model.ID, documentation string) error
CreateEntityFunc func(domainModelID model.ID, entity *domainmodel.Entity) error
UpdateEntityFunc func(domainModelID model.ID, entity *domainmodel.Entity) error
DeleteEntityFunc func(domainModelID model.ID, entityID model.ID) error
Expand Down Expand Up @@ -155,6 +156,7 @@ type MockBackend struct {
SetProjectSecurityLevelFunc func(unitID model.ID, level string) error
SetProjectDemoUsersEnabledFunc func(unitID model.ID, enabled bool) error
SetProjectStrictModeFunc func(unitID model.ID, enabled bool) error
SetProjectAdminUserNameFunc func(unitID model.ID, name string) error
SetProjectGuestAccessFunc func(unitID model.ID, enabled bool, guestUserRole string) error
AddUserRoleFunc func(unitID model.ID, name string, moduleRoles []string, manageAllRoles bool) error
AlterUserRoleModuleRolesFunc func(unitID model.ID, userRoleName string, add bool, moduleRoles []string) error
Expand Down
7 changes: 7 additions & 0 deletions mdl/backend/mock/mock_domainmodel.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ func (m *MockBackend) SetDomainModelAnnotations(domainModelID model.ID, annotati
return fmt.Errorf("MockBackend.SetDomainModelAnnotations not configured")
}

func (m *MockBackend) SetDomainModelDocumentation(domainModelID model.ID, documentation string) error {
if m.SetDomainModelDocumentationFunc != nil {
return m.SetDomainModelDocumentationFunc(domainModelID, documentation)
}
return fmt.Errorf("MockBackend.SetDomainModelDocumentation not configured")
}

func (m *MockBackend) CreateEntity(domainModelID model.ID, entity *domainmodel.Entity) error {
if m.CreateEntityFunc != nil {
return m.CreateEntityFunc(domainModelID, entity)
Expand Down
7 changes: 7 additions & 0 deletions mdl/backend/mock/mock_security.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ func (m *MockBackend) SetProjectDemoUsersEnabled(unitID model.ID, enabled bool)
return nil
}

func (m *MockBackend) SetProjectAdminUserName(unitID model.ID, name string) error {
if m.SetProjectAdminUserNameFunc != nil {
return m.SetProjectAdminUserNameFunc(unitID, name)
}
return fmt.Errorf("MockBackend.SetProjectAdminUserName not configured")
}

func (m *MockBackend) SetProjectStrictMode(unitID model.ID, enabled bool) error {
if m.SetProjectStrictModeFunc != nil {
return m.SetProjectStrictModeFunc(unitID, enabled)
Expand Down
16 changes: 16 additions & 0 deletions mdl/backend/modelsdk/domainmodel_alter.go
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,22 @@ func (b *Backend) SetDomainModelAnnotations(domainModelID model.ID, annotations
return b.persistDM(domainModelID, gdm)
}

// SetDomainModelDocumentation sets DomainModels$DomainModel.Documentation, the
// domain model's own documentation and the only documentation a module has
// (mendixlabs/mxcli#1314). It loads the stored unit and changes that one
// property, so entities, associations and annotations pass through as stored.
func (b *Backend) SetDomainModelDocumentation(domainModelID model.ID, documentation string) error {
if b.writer == nil {
return fmt.Errorf("SetDomainModelDocumentation: not connected for writing")
}
gdm, err := b.loadDomainModelGen(domainModelID)
if err != nil {
return err
}
gdm.SetDocumentation(documentation)
return b.persistDM(domainModelID, gdm)
}

// entityIsExternal reports whether a stored entity is an external (OData) one,
// whose attributes carry mapped remote values rather than plain Mendix types.
//
Expand Down
122 changes: 122 additions & 0 deletions mdl/backend/modelsdk/issue1314_documentation_admin_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: Apache-2.0

package modelsdkbackend

import (
"testing"

"go.mongodb.org/mongo-driver/bson"

"github.com/mendixlabs/mxcli/model"
)

// storedString reads a top-level string property from a unit as stored.
func storedString(t *testing.T, b *Backend, id model.ID, key string) string {
t.Helper()
raw, err := b.GetRawUnitBytes(id)
if err != nil {
t.Fatalf("GetRawUnitBytes: %v", err)
}
var d bson.D
if err := bson.Unmarshal(raw, &d); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, e := range d {
if e.Key == key {
s, _ := e.Value.(string)
return s
}
}
t.Fatalf("unit %s has no %s property", id, key)
return ""
}

// mendixlabs/mxcli#1314: the domain model's Documentation is written on the
// DomainModels$DomainModel unit — the property #1269 reads — and survives a
// reconnect.
func TestSetDomainModelDocumentation(t *testing.T) {
proj := copyFixture(t)
b := New()
if err := b.Connect(proj); err != nil {
t.Fatalf("connect: %v", err)
}
dms, err := b.ListDomainModels()
if err != nil || len(dms) == 0 {
t.Fatalf("ListDomainModels: %v (%d)", err, len(dms))
}
var dmID model.ID
for _, dm := range dms {
if dm.Documentation == "" && dm.ContainerID != "" {
if _, err := b.loadDomainModelGen(dm.ID); err == nil {
dmID = dm.ID
break
}
}
}
if dmID == "" {
t.Fatal("fixture has no stored, undocumented domain model")
}
// Control: the fixture starts undocumented.
if got := storedString(t, b, dmID, "Documentation"); got != "" {
t.Fatalf("fixture domain model already documented: %q", got)
}

if err := b.SetDomainModelDocumentation(dmID, "Sales data"); err != nil {
t.Fatalf("SetDomainModelDocumentation: %v", err)
}
_ = b.Disconnect()

b = New()
if err := b.Connect(proj); err != nil {
t.Fatalf("reconnect: %v", err)
}
t.Cleanup(func() { _ = b.Disconnect() })
if got := storedString(t, b, dmID, "Documentation"); got != "Sales data" {
t.Errorf("stored Documentation = %q, want %q", got, "Sales data")
}
dm, err := b.GetDomainModelByID(dmID)
if err != nil || dm.Documentation != "Sales data" {
t.Errorf("read back %+v (%v), want Documentation %q", dm, err, "Sales data")
}
}

// mendixlabs/mxcli#1314: AdminUserName is written on Security$ProjectSecurity
// and nothing else about the administrator changes.
func TestSetProjectAdminUserName(t *testing.T) {
proj := copyFixture(t)
b := New()
if err := b.Connect(proj); err != nil {
t.Fatalf("connect: %v", err)
}
ps, err := b.GetProjectSecurity()
if err != nil {
t.Fatalf("GetProjectSecurity: %v", err)
}
// Control: the fixture has the default administrator name.
before := storedString(t, b, ps.ID, "AdminUserName")
if before == "" || before == "appadmin" {
t.Fatalf("fixture AdminUserName = %q; the control does not hold", before)
}
pwBefore := storedString(t, b, ps.ID, "AdminPassword")

if err := b.SetProjectAdminUserName(ps.ID, "appadmin"); err != nil {
t.Fatalf("SetProjectAdminUserName: %v", err)
}
_ = b.Disconnect()

b = New()
if err := b.Connect(proj); err != nil {
t.Fatalf("reconnect: %v", err)
}
t.Cleanup(func() { _ = b.Disconnect() })
if got := storedString(t, b, ps.ID, "AdminUserName"); got != "appadmin" {
t.Errorf("stored AdminUserName = %q, want appadmin", got)
}
if got := storedString(t, b, ps.ID, "AdminPassword"); got != pwBefore {
t.Errorf("AdminPassword changed: %q -> %q", pwBefore, got)
}
got, err := b.GetProjectSecurity()
if err != nil || got.AdminUserName != "appadmin" {
t.Errorf("read back AdminUserName %q (%v), want appadmin", got.AdminUserName, err)
}
}
Loading
Loading