Skip to content

fix(security): GRANT widens an access rule instead of replacing it - #194

Merged
ako merged 4 commits into
mainfrom
claude/mxcli-issues-ovfoxk
Aug 20, 2026
Merged

fix(security): GRANT widens an access rule instead of replacing it#194
ako merged 4 commits into
mainfrom
claude/mxcli-issues-ovfoxk

Conversation

@ako

@ako ako commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Fixes mendixlabs#936 — reported as a silent security regression, and it is one.

The report needs three corrections

Re-granting a role on an entity shrank the rule instead of widening it:

GRANT Viewer ON Customer (READ (Name, Email)) WHERE '[…]';   -- read (Name, Email)
GRANT Viewer ON Customer (READ (Phone))       WHERE '[…]';   -- read (Phone)  ← Name, Email gone

No error, no warning, no diff. Confirmed and persisted to disk. But three things in the report are wrong in ways that change the fix:

1. WHERE is a red herring. The identical sequence without a constraint loses just as much (read (Name, Email)read (Phone)), and so does READ *READ (Phone). The report's "GRANT without WHERE behaves additively" does not hold — most likely they only ever re-ran READ *, which looks unchanged. A fix scoped to the constrained path would have passed the reporter's own repro and left most of the bug in place.

2. It is worse than the attribute list. Structural rights go the same way:

(create, delete, read *, write *)  →  re-grant (READ (Name))  →  (read (Name))

CREATE, DELETE and every write right silently revoked. The report only noticed attributes.

3. It is an engine regression, not a longstanding gap. The same script under MXCLI_ENGINE=legacy yields read (Name, Email, Phone) and keeps create, delete, read *, write *. Legacy has had mergeAccessRule since it shipped; the codec engine — the default — never got an equivalent. That is why the documented contract ("GRANT is additive … never removes permissions") held on one engine and not the one everybody uses.

Running the other engine as a control is what supplied the merge semantics rather than my having to invent them: OR create/delete, take the higher default, take the higher rights per member.

Root cause

AddEntityAccessRule (codec engine) found the matching rule, deleted every MemberAccess, and rebuilt from the current statement alone. The executor emits an entry for every member of the entity — unnamed ones at the rule's default, normally None — so each GRANT reset every member it did not mention.

Worth noting the third backend: MCP refuses this outright rather than replacing, which is the ADR-0005 posture. Three implementations, three behaviours; only the default one destroyed data.

A second defect, found while measuring the first

The upsert matched a stored rule on its module-role set alone, ignoring XPathConstraint — in both engines:

GRANT Viewer ON Customer (READ (Name))  WHERE '[Name = 1]';
GRANT Viewer ON Customer (READ (Email)) WHERE '[Name = 2]';
-- → one rule: (read (Email)) where '[Name = 2]'   ← first rule destroyed

Mendix's reference guide is explicit that this is legitimate model content: "Rules are additive — if multiple access rules apply to the same module role, all access rights of those rules are combined." One rule per constraint is the ordinary way to write row-level security, and MDL could not express it at all.

The fix

  • A — the codec engine merges instead of rebuilding: reads the matched rule's existing rights, ORs create/delete, takes the higher default, takes the higher rights per member.
  • BXPathConstraint joins the match key on both engines. The empty constraint is a value, not a wildcard, so a constrained and an unconstrained rule coexist, and re-running a script stays idempotent (ADR-0008) because each statement lands on its own rule.

Rights merge on None < ReadOnly < ReadWrite, so a merge only ever widens. Narrowing stays REVOKE's job — that is what keeps the two commands inverses rather than two spellings of "set".

The lattice moved to mdl/types (AccessRightsLevel / HigherAccessRights) and legacy now delegates to it. The engines silently diverging on this is what caused the bug; sharing it means they can't drift again.

One consequence worth reviewing

Fix B breaks anything keyed on roles alone. formatAccessRuleResult was, so once several rules per role existed it echoed a different rule's rights back at the user. It now takes the constraint too (REVOKE passes anyXPath, since it narrows every rule the roles appear in).

Pleasant side effect: that function reads back from storage, so a merging GRANT now prints the merged rule —

GRANT … (READ (Phone));      →  Result: read (Name, Email, Phone)

— which is exactly the visible signal whose absence the report was complaining about.

Verification

  • 7 new backend tests, written first and confirmed failing with the reported symptom verbatim: map[Email:None Name:None Phone:ReadOnly]. The idempotence test passed before the fix, so it guards a regression fix B could have introduced rather than restating the bug.
  • Reported repro end to end on both engines → read (Name, Email, Phone).
  • Structural case → create, delete, read *, write * preserved. Unconstrained case → merged. Two constraints → two rules, both preserved, and describe round-trips them as two statements.
  • mx check: 0 errors on the two-rule model. The decisive check for fix B, since it now produces rules where there was one — Mendix accepts it, confirming the refguide.
  • make check-mdl green including the new fixture; 76 packages green; gofmt clean; vet clean (remaining warnings are pre-existing, in generated ANTLR code).

Docs

The four places asserting "GRANT is additive" are now true rather than aspirational. Added the one-rule-per-constraint pattern to docs-site and to the synced manage-security skill, so agents writing MDL know a where creates a rule rather than narrowing one. Symptom table and CHANGELOG.md updated; fixture at mdl-examples/bug-tests/936-grant-additive-merge.mdl covers all five cases.

Notes for the reviewer

  • Behaviour change beyond the bug: a GRANT with no WHERE following a constrained one used to inherit the stored constraint (legacy's mergeAccessRule kept the existing XPath when the new one was empty), quietly constraining access the user asked to be unconstrained. It now matches — or creates — the unconstrained rule. I believe that is the correct reading, but it is the one place where existing scripts could produce a different model.
  • Not addressed: RemoveEntityAccessRule / RevokeEntityMemberAccess still match on roles alone, which is right for REVOKE (it should narrow every rule the role appears in) but means there is currently no way to revoke one constrained rule while leaving its sibling. That is a feature gap, not a data-loss path, so I left it out of scope.

Generated by Claude Code

claude and others added 4 commits August 19, 2026 22:23
Re-granting a role on an entity rebuilt the rule from that one statement,
so anything an earlier GRANT had allowed came back None: (READ (Name,
Email)) followed by (READ (Phone)) left a rule reading Phone alone.
Structural rights went the same way -- (CREATE, DELETE, READ *, WRITE *)
followed by a narrow re-grant lost create, delete and every write right,
which the report did not mention. Nothing was said at any point.

The reported trigger was wrong in a way that matters: WHERE is not
required. The same loss reproduces unconstrained and with READ *, so a
fix scoped to the constrained path would have satisfied the repro and
left most of the bug. The legacy engine has merged additively since it
shipped (mergeAccessRule), making this a regression in the codec engine
-- the default -- and explaining why the documented contract ("GRANT is
additive ... never removes permissions") held on one engine only.

A second defect surfaced while measuring the first: both engines matched
a stored rule on its module-role set alone, ignoring XPathConstraint, so
GRANT ... WHERE 'A' followed by WHERE 'B' folded the second onto the
first and overwrote its constraint. Mendix combines the rights of every
rule naming a role ("Rules are additive", refguide/access-rules), so one
rule per constraint is the ordinary way to write row-level security -- a
pattern MDL could not express. The constraint now belongs to the match
key on both engines, with the empty constraint treated as a value rather
than a wildcard, so constrained and unconstrained rules coexist and
re-running a script stays idempotent (ADR-0008).

Consequently formatAccessRuleResult needed the constraint too, or the
Result: line echoed a different rule's rights back at the user; REVOKE
passes anyXPath since it narrows every rule the roles appear in.

Rights merge on None < ReadOnly < ReadWrite, so a merge only ever widens.
Narrowing stays REVOKE's job, which keeps the two commands inverses
rather than two spellings of "set". The lattice is shared via mdl/types
so the engines cannot drift.

Verified: 7 new backend tests (failing first, with the reported
map[Email:None Name:None Phone:ReadOnly]); the reported repro end to end
on both engines; mx check 0 errors on the two-rule form; make check-mdl
green; 76 packages green.

Fixes mendixlabs#936

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
mendixlabs#923)

DESCRIBE MICROFLOW renders control flow as nested if/then/else, which only
works for properly nested graphs. When a branch re-enters a sibling's path
the describer walks it as a tree and emits MDL that means something else,
silently.

Measured on the reporter's own graph, reconstructed from the coordinates in
their describe output: with their actual expressions the original ALWAYS
logs and the description NEVER does -- the exact inverse, not merely a
semantic difference. The same root cause explains the tangled diagram they
reported separately: findSplitMergePointsForGraph and commonMergeAfter
disagree about which node is split1's merge, so the emitted @merge places it
before an activity that structurally follows it.

Records the negative BSON finding that forecloses the obvious design:
Microflows$ExclusiveMerge has no name, caption or documentation field, per
generated/metamodel, modelsdk/gen, and real 11.13 Studio Pro documents.
Labels therefore cannot be stored -- but they do not need to be, since a
label is an artifact of the emitted text and only has to be deterministic,
not persistent.

Proposes three modes: structured (today, unchanged), a faithful merge/join
label form that also subsumes the @merge annotation, and an opt-in
normalized form that recombines guards. Normalization is bounded by
Bohm-Jacopini -- it works where the extra edges land on a shared suffix, and
is refused where branches genuinely interleave, since that needs either
activity duplication or a variable the user never wrote.

Phase 0 is the detector, shipped as a lint rule so prevalence can be
measured before Modes 1 and 2 are scheduled. It is independently the fix for
the issue: a silent inversion becomes a named refusal.

Refs mendixlabs#923

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
…ully

DESCRIBE MICROFLOW renders control flow as nested IF/THEN/ELSE, which only
works when the graph is properly nested. A Mendix microflow is an arbitrary
graph, and when a branch re-enters a sibling branch's path there is no
nesting that means the same thing -- the describer emitted one anyway, with
no error and no warning.

Measured on the reporter's graph, reconstructed from the coordinates in
their own describe output: the log activity ran on NOT c1 OR c2 and was
described as c1 AND c2. With their actual expressions (not(true) on both
decisions) the original ALWAYS logs and the description NEVER does -- the
exact inverse, so re-executing it produced the opposite program. The
separate complaint about the diagram coming back tangled is the same cause:
findSplitMergePointsForGraph and commonMergeAfter are two independent
merge-finders that agree on every nested graph and disagree here, so the
emitted @merge lands before an activity that structurally follows it.

Phase 0 of PROPOSAL_structured_microflow_description.md: detection only.

- mdl/microflowgraph: post-dominance plus branch-body overlap, classifying
  an overlap with one entry as recombinable (the guards fold) and two or
  more as interleaved (needs activity duplication or a synthetic boolean per
  Bohm-Jacopini, so it is refused rather than rewritten). Deliberately does
  NOT reuse the describer's join search: mxcli has two and they disagree on
  exactly these graphs, so a detector built on either inherits whichever is
  wrong. It also cannot live in mdl/executor, which imports mdl/linter.
- MDL-FLOW01 reports them, with different advice per class.
- DESCRIBE emits a -- WARNING: comment naming the decision's position and
  refusing the round trip.

Not registered in mxcli report: that score grades the model, and the model
is valid -- what fails is mxcli's ability to describe it.

The false positive would be worse than the bug, so the negative controls
pin the shapes most likely to trip it: if with no else, an inner split whose
join is the outer's, branches that both return, retry-loop back edges, and
error-handler flows. Wiring proved with a forced-fire control against a real
project, since a rule that never runs and a rule that finds nothing look
identical. DgDemo reports the same 25 issues as before.

Refs mendixlabs#923

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013uQvFDd5R4eNqqita59jM8
@ako
ako merged commit 2246e39 into main Aug 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GRANT ... WHERE overwrites instead of merging the attribute list (silent security regression)

2 participants