fix(security): GRANT widens an access rule instead of replacing it - #194
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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.
WHEREis a red herring. The identical sequence without a constraint loses just as much (read (Name, Email)→read (Phone)), and so doesREAD *→READ (Phone). The report's "GRANT without WHERE behaves additively" does not hold — most likely they only ever re-ranREAD *, 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 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=legacyyieldsread (Name, Email, Phone)and keepscreate, delete, read *, write *. Legacy has hadmergeAccessRulesince 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, normallyNone— 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: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
XPathConstraintjoins 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.
formatAccessRuleResultwas, 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 passesanyXPath, 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 —
— which is exactly the visible signal whose absence the report was complaining about.
Verification
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.read (Name, Email, Phone).create, delete, read *, write *preserved. Unconstrained case → merged. Two constraints → two rules, both preserved, anddescriberound-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-mdlgreen 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-siteand to the syncedmanage-securityskill, so agents writing MDL know awherecreates a rule rather than narrowing one. Symptom table andCHANGELOG.mdupdated; fixture atmdl-examples/bug-tests/936-grant-additive-merge.mdlcovers all five cases.Notes for the reviewer
GRANTwith noWHEREfollowing a constrained one used to inherit the stored constraint (legacy'smergeAccessRulekept 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.RemoveEntityAccessRule/RevokeEntityMemberAccessstill 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