Skip to content

Commit 0b3d9c2

Browse files
akoclaude
andcommitted
Fix CE0066 entity access out of date errors
Two root causes: 1. GRANT with READ */WRITE * wrote empty MemberAccesses array, relying on DefaultMemberAccessRights. Mendix requires explicit MemberAccess entries for every attribute/association — empty array triggers CE0066. 2. The exec command and -c flag iterated statements individually with Execute() instead of ExecuteProgram(), so finalizeProgramExecution() never ran. Stale MemberAccess entries after DROP ATTRIBUTE were never cleaned up by ReconcileMemberAccesses. Verified all 9 CE0066 scenarios pass mx check with 0 errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b620a1c commit 0b3d9c2

4 files changed

Lines changed: 212 additions & 65 deletions

File tree

cmd/mxcli/cmd_exec.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,12 @@ Example:
5858
os.Exit(1)
5959
}
6060

61-
for _, stmt := range prog.Statements {
62-
if err := exec.Execute(stmt); err != nil {
63-
if errors.Is(err, executor.ErrExit) {
64-
return
65-
}
66-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
67-
os.Exit(1)
61+
if err := exec.ExecuteProgram(prog); err != nil {
62+
if errors.Is(err, executor.ErrExit) {
63+
return
6864
}
65+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
66+
os.Exit(1)
6967
}
7068
},
7169
}

cmd/mxcli/main.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,12 @@ Examples:
102102
os.Exit(1)
103103
}
104104

105-
for _, stmt := range prog.Statements {
106-
if err := exec.Execute(stmt); err != nil {
107-
if errors.Is(err, executor.ErrExit) {
108-
return
109-
}
110-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
111-
os.Exit(1)
105+
if err := exec.ExecuteProgram(prog); err != nil {
106+
if errors.Is(err, executor.ErrExit) {
107+
return
112108
}
109+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
110+
os.Exit(1)
113111
}
114112
} else {
115113
// Start interactive REPL
@@ -168,14 +166,12 @@ func executeMDL(projectPath, mdlCmd string) {
168166
os.Exit(1)
169167
}
170168

171-
for _, stmt := range prog.Statements {
172-
if err := exec.Execute(stmt); err != nil {
173-
if errors.Is(err, executor.ErrExit) {
174-
return
175-
}
176-
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
177-
os.Exit(1)
169+
if err := exec.ExecuteProgram(prog); err != nil {
170+
if errors.Is(err, executor.ErrExit) {
171+
return
178172
}
173+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
174+
os.Exit(1)
179175
}
180176
}
181177

mdl/executor/cmd_security_write.go

Lines changed: 45 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -310,61 +310,62 @@ func (e *Executor) execGrantEntityAccess(s *ast.GrantEntityAccessStmt) error {
310310
}
311311
}
312312

313-
// Build explicit MemberAccesses when specific members are listed
313+
// Build MemberAccess entries for all entity attributes and associations.
314+
// Mendix requires explicit MemberAccess entries for every member — an empty
315+
// MemberAccesses array triggers CE0066 "Entity access is out of date".
314316
var memberAccesses []mpr.EntityMemberAccess
315-
if readMembers != nil || writeMembers != nil {
316-
// Build sets for quick lookup
317-
writeMemberSet := make(map[string]bool)
318-
for _, m := range writeMembers {
319-
writeMemberSet[m] = true
320-
}
321-
readMemberSet := make(map[string]bool)
322-
for _, m := range readMembers {
323-
readMemberSet[m] = true
317+
318+
// Build sets for specific member overrides (when READ (Name, Email) syntax is used)
319+
writeMemberSet := make(map[string]bool)
320+
for _, m := range writeMembers {
321+
writeMemberSet[m] = true
322+
}
323+
readMemberSet := make(map[string]bool)
324+
for _, m := range readMembers {
325+
readMemberSet[m] = true
326+
}
327+
328+
// Create entries for all entity attributes
329+
for _, attr := range entity.Attributes {
330+
rights := defaultMemberAccess
331+
if writeMemberSet[attr.Name] {
332+
rights = "ReadWrite"
333+
} else if readMemberSet[attr.Name] {
334+
rights = "ReadOnly"
324335
}
336+
memberAccesses = append(memberAccesses, mpr.EntityMemberAccess{
337+
AttributeRef: module.Name + "." + s.Entity.Name + "." + attr.Name,
338+
AccessRights: rights,
339+
})
340+
}
325341

326-
// Create entries for all entity attributes
327-
for _, attr := range entity.Attributes {
342+
// Create entries for associations owned by this entity
343+
for _, assoc := range dm.Associations {
344+
if assoc.ParentID == entity.ID {
328345
rights := defaultMemberAccess
329-
if writeMemberSet[attr.Name] {
346+
if writeMemberSet[assoc.Name] {
330347
rights = "ReadWrite"
331-
} else if readMemberSet[attr.Name] {
348+
} else if readMemberSet[assoc.Name] {
332349
rights = "ReadOnly"
333350
}
334351
memberAccesses = append(memberAccesses, mpr.EntityMemberAccess{
335-
AttributeRef: module.Name + "." + s.Entity.Name + "." + attr.Name,
336-
AccessRights: rights,
352+
AssociationRef: module.Name + "." + assoc.Name,
353+
AccessRights: rights,
337354
})
338355
}
339-
340-
// Create entries for associations owned by this entity
341-
for _, assoc := range dm.Associations {
342-
if assoc.ParentID == entity.ID {
343-
rights := defaultMemberAccess
344-
if writeMemberSet[assoc.Name] {
345-
rights = "ReadWrite"
346-
} else if readMemberSet[assoc.Name] {
347-
rights = "ReadOnly"
348-
}
349-
memberAccesses = append(memberAccesses, mpr.EntityMemberAccess{
350-
AssociationRef: module.Name + "." + assoc.Name,
351-
AccessRights: rights,
352-
})
353-
}
354-
}
355-
for _, ca := range dm.CrossAssociations {
356-
if ca.ParentID == entity.ID {
357-
rights := defaultMemberAccess
358-
if writeMemberSet[ca.Name] {
359-
rights = "ReadWrite"
360-
} else if readMemberSet[ca.Name] {
361-
rights = "ReadOnly"
362-
}
363-
memberAccesses = append(memberAccesses, mpr.EntityMemberAccess{
364-
AssociationRef: module.Name + "." + ca.Name,
365-
AccessRights: rights,
366-
})
356+
}
357+
for _, ca := range dm.CrossAssociations {
358+
if ca.ParentID == entity.ID {
359+
rights := defaultMemberAccess
360+
if writeMemberSet[ca.Name] {
361+
rights = "ReadWrite"
362+
} else if readMemberSet[ca.Name] {
363+
rights = "ReadOnly"
367364
}
365+
memberAccesses = append(memberAccesses, mpr.EntityMemberAccess{
366+
AssociationRef: module.Name + "." + ca.Name,
367+
AccessRights: rights,
368+
})
368369
}
369370
}
370371

mdl/executor/roundtrip_mxcheck_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,158 @@ END;`
297297
}
298298
}
299299

300+
// --- CE0066 "Entity access is out of date" scenario tests ---
301+
//
302+
// These tests enumerate mutation orderings that might trigger CE0066 when
303+
// entity structure changes after access rules have been written.
304+
//
305+
// Scenarios to test:
306+
// S1: CREATE ENTITY (with attrs) → GRANT (baseline)
307+
// S2: CREATE ENTITY → GRANT → ALTER ADD ATTRIBUTE (attribute added after security)
308+
// S3: CREATE ENTITY → GRANT → CREATE ASSOCIATION (association added after security)
309+
// S4: CREATE ENTITY → GRANT → ALTER DROP ATTRIBUTE (attribute removed after security)
310+
// S5: CREATE ENTITY → ALTER ADD ATTRIBUTE → GRANT (security after mutation — should always work)
311+
// S6: CREATE ENTITY → GRANT(READ *) → ALTER → GRANT(READ *) (re-grant after mutation)
312+
// S7: CREATE ENTITY + GRANT in single script (typical user script pattern)
313+
// S8: CREATE ENTITY → GRANT multiple roles → ALTER (multiple rules + mutation)
314+
// S9: CREATE ENTITY → GRANT → ALTER + CREATE ASSOC (combined) (both attrs and assocs change)
315+
316+
// ce0066Scenario describes a single CE0066 test case.
317+
type ce0066Scenario struct {
318+
name string // subtest name
319+
steps []string // MDL statements to execute in order
320+
}
321+
322+
func TestMxCheck_CE0066_Scenarios(t *testing.T) {
323+
if !mxCheckAvailable() {
324+
t.Skip("mx command not available")
325+
}
326+
327+
mod := testModule
328+
scenarios := []ce0066Scenario{
329+
{
330+
name: "S1_CreateEntity_Grant",
331+
steps: []string{
332+
`CREATE MODULE ROLE ` + mod + `.S1Admin;`,
333+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S1Entity (Name: String(100), Email: String(200));`,
334+
`GRANT ` + mod + `.S1Admin ON ` + mod + `.S1Entity (CREATE, DELETE, READ *, WRITE *);`,
335+
},
336+
},
337+
{
338+
name: "S2_Grant_ThenAddAttribute",
339+
steps: []string{
340+
`CREATE MODULE ROLE ` + mod + `.S2Admin;`,
341+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S2Entity (Name: String(100));`,
342+
`GRANT ` + mod + `.S2Admin ON ` + mod + `.S2Entity (CREATE, DELETE, READ *, WRITE *);`,
343+
`ALTER ENTITY ` + mod + `.S2Entity ADD ATTRIBUTE Email String(200);`,
344+
`ALTER ENTITY ` + mod + `.S2Entity ADD ATTRIBUTE Active Boolean DEFAULT false;`,
345+
},
346+
},
347+
{
348+
name: "S3_Grant_ThenAddAssociation",
349+
steps: []string{
350+
`CREATE MODULE ROLE ` + mod + `.S3Admin;`,
351+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S3Parent (Name: String(100));`,
352+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S3Child (Label: String(100));`,
353+
`GRANT ` + mod + `.S3Admin ON ` + mod + `.S3Parent (CREATE, DELETE, READ *, WRITE *);`,
354+
`CREATE ASSOCIATION ` + mod + `.S3Child_S3Parent (` + mod + `.S3Child -> ` + mod + `.S3Parent);`,
355+
},
356+
},
357+
{
358+
name: "S4_Grant_ThenDropAttribute",
359+
steps: []string{
360+
`CREATE MODULE ROLE ` + mod + `.S4Admin;`,
361+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S4Entity (Name: String(100), Temp: String(50));`,
362+
`GRANT ` + mod + `.S4Admin ON ` + mod + `.S4Entity (CREATE, DELETE, READ *, WRITE *);`,
363+
`ALTER ENTITY ` + mod + `.S4Entity DROP ATTRIBUTE Temp;`,
364+
},
365+
},
366+
{
367+
name: "S5_AddAttribute_ThenGrant",
368+
steps: []string{
369+
`CREATE MODULE ROLE ` + mod + `.S5Admin;`,
370+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S5Entity (Name: String(100));`,
371+
`ALTER ENTITY ` + mod + `.S5Entity ADD ATTRIBUTE Email String(200);`,
372+
`GRANT ` + mod + `.S5Admin ON ` + mod + `.S5Entity (CREATE, DELETE, READ *, WRITE *);`,
373+
},
374+
},
375+
{
376+
name: "S6_Grant_Alter_Regrant",
377+
steps: []string{
378+
`CREATE MODULE ROLE ` + mod + `.S6Admin;`,
379+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S6Entity (Name: String(100));`,
380+
`GRANT ` + mod + `.S6Admin ON ` + mod + `.S6Entity (READ *);`,
381+
`ALTER ENTITY ` + mod + `.S6Entity ADD ATTRIBUTE Code String(50);`,
382+
`GRANT ` + mod + `.S6Admin ON ` + mod + `.S6Entity (CREATE, DELETE, READ *, WRITE *);`,
383+
},
384+
},
385+
{
386+
name: "S7_SingleScript_CreateAndGrant",
387+
steps: []string{
388+
`CREATE MODULE ROLE ` + mod + `.S7Admin;`,
389+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S7Entity (
390+
Code: String(50) NOT NULL,
391+
Description: String(500),
392+
Count: Integer DEFAULT 0
393+
);` + "\n" +
394+
`GRANT ` + mod + `.S7Admin ON ` + mod + `.S7Entity (CREATE, DELETE, READ *, WRITE *);`,
395+
},
396+
},
397+
{
398+
name: "S8_MultipleRoles_ThenAlter",
399+
steps: []string{
400+
`CREATE MODULE ROLE ` + mod + `.S8Admin;`,
401+
`CREATE MODULE ROLE ` + mod + `.S8Viewer;`,
402+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S8Entity (Name: String(100));`,
403+
`GRANT ` + mod + `.S8Admin ON ` + mod + `.S8Entity (CREATE, DELETE, READ *, WRITE *);`,
404+
`GRANT ` + mod + `.S8Viewer ON ` + mod + `.S8Entity (READ *);`,
405+
`ALTER ENTITY ` + mod + `.S8Entity ADD ATTRIBUTE Status String(50);`,
406+
},
407+
},
408+
{
409+
name: "S9_Grant_ThenAlterAndAssoc",
410+
steps: []string{
411+
`CREATE MODULE ROLE ` + mod + `.S9Admin;`,
412+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S9Main (Name: String(100));`,
413+
`CREATE OR MODIFY PERSISTENT ENTITY ` + mod + `.S9Related (Value: Integer);`,
414+
`GRANT ` + mod + `.S9Admin ON ` + mod + `.S9Main (CREATE, DELETE, READ *, WRITE *);`,
415+
`ALTER ENTITY ` + mod + `.S9Main ADD ATTRIBUTE Extra String(200);`,
416+
`CREATE ASSOCIATION ` + mod + `.S9Related_S9Main (` + mod + `.S9Related -> ` + mod + `.S9Main);`,
417+
},
418+
},
419+
}
420+
421+
for _, sc := range scenarios {
422+
t.Run(sc.name, func(t *testing.T) {
423+
env := setupTestEnv(t)
424+
defer env.teardown()
425+
426+
for i, step := range sc.steps {
427+
if err := env.executeMDL(step); err != nil {
428+
if !strings.Contains(err.Error(), "already exists") {
429+
t.Fatalf("Step %d failed: %v\nMDL: %s", i+1, err, step)
430+
}
431+
}
432+
}
433+
434+
env.executor.Execute(&ast.DisconnectStmt{})
435+
436+
output, err := runMxCheck(t, env.projectPath)
437+
if err != nil {
438+
if strings.Contains(output, "CE0066") || strings.Contains(output, "out of date") {
439+
t.Errorf("CE0066 entity access out of date:\n%s", output)
440+
} else if strings.Contains(output, "error") || strings.Contains(output, "Error") {
441+
t.Errorf("mx check found errors:\n%s", output)
442+
} else {
443+
t.Logf("mx check output (non-zero exit but no errors):\n%s", output)
444+
}
445+
} else {
446+
t.Logf("mx check passed")
447+
}
448+
})
449+
}
450+
}
451+
300452
// TestMxCheck_MicroflowWithCallParams tests microflow with CALL unified param syntax.
301453
func TestMxCheck_MicroflowWithCallParams(t *testing.T) {
302454
if !mxCheckAvailable() {

0 commit comments

Comments
 (0)