Someone on your team unticks Read on Customer for a role, to stop that role browsing the customer list. The User Permission rows restricting them to three customers stay in place. The list view now hard-denies them, exactly as intended. Then they open General Ledger — and see every party in the company. That is not a misconfiguration you can spot in the UI. On Frappe version-15, build_match_conditions() returns an empty string for that exact combination, and every caller in ERPNext reads an empty string as "no restriction". The more locked-down configuration is the one that returns unfiltered rows. This is tracked as frappe/frappe#41270, open since 27 July 2026 with zero comments and no linked fix.
Running more than one company?
The doctype people most often restrict this way is Company. If you are still designing that structure, read the ERPNext multi-company setup guide first — getting the permission model right there prevents most of what this post is about.
I am Manoj, ERP implementation lead at Mith Tech in Bengaluru. This one is worth reading carefully rather than skimming, because the headline — "reports ignore User Permissions" — is true on one major version and inverted on the next. Version-15 users can leak rows. Version-16 users get the opposite failure: reports that abort or come back empty for no visible reason. Both trace back to the same seam.
How we verified this
Everything in this post is traced from source. I read frappe/model/db_query.py, frappe/database/query.py, frappe/desk/reportview.py and frappe/desk/query_report.py on version-15, version-16 and develop HEAD as of 5 August 2026, plus the ERPNext report call sites on version-15 and develop, and cross-checked the GitHub issue and PR state through the API.
Nothing was executed against a live bench. I have no Frappe instance in this environment. The '' return value is the issue reporter's observation in #41270; I traced the control flow independently and it is consistent with what they describe, but I did not reproduce it. The console snippets below are written from the verified source and are not execution-tested — read them before you run them, and run the audit pass read-only first.
This is a permission-enforcement bug, not a CVE. There is no severity score attached to it and I am not assigning one. It widens which rows a user sees inside a report they were already allowed to open. It does not grant access to a doctype they could not otherwise reach, does not affect list views or the REST API, and does not allow writing or deleting anything.
Start here: what does your version actually do?
Pick your Frappe major version, how the match condition is built, what the user's roles grant on the doctype, and whether User Permission rows exist. Get the exact return value, what the caller does with it, and whether that is a leak, a phantom-empty report, or correct behaviour.
On version-15, frappe.desk.reportview.build_match_conditions() routes into the legacy DatabaseQuery. The User Permission rows cause the deny gate to be skipped, and the missing read/select causes the apply gate to be skipped. The function returns an empty condition, callers such as ERPNext's General Ledger treat that as "no restriction", and frappe.desk.query_report.get_filtered_data() falls through to result = list(data) — every row.
Verdicts are derived by reading frappe/model/db_query.py, frappe/database/query.py and frappe/desk/query_report.py on version-15, version-16 and develop HEAD (5 August 2026). Nothing was executed against a live bench — run the probe in the recipe below against your own instance before acting.
The short version, before the mechanism: the exploitable fail-open through the standard report path is version-15 only. On version-16 and develop, frappe.desk.reportview.build_match_conditions() no longer routes into the legacy engine. PR #35857, merged 3 February 2026 and backported to version-16 by PR #36618, points it at frappe/database/query.py::Engine instead. That engine fails closed — with no read or select and no shared documents it raises rather than returning an empty string, and its as_condition=False path applies User Permissions with no read/select gate at all.
The legacy function itself is still there and still unfixed on all three branches. On version-16 and develop it is only reachable by code that imports DatabaseQuery from frappe.model.db_query and calls it directly — your own app, a server script, a third-party app. That is the residual exposure on modern versions, and it is a much narrower target than the standard report path.
The two gates
The function is DatabaseQuery.build_match_conditions in frappe/model/db_query.py — line 1312 on version-15, line 1025 on version-16, line 1200 on develop. It is functionally identical on all three.
There are two independent gates in it, and the bug is the gap between them.
Gate A, the deny gate, fires only when all of these hold: the doctype is not a child table, the role grants neither select nor read, ignore_permissions is falsy, and has_any_user_permission_for_doctype() is False. Only inside Gate A can a PermissionError be thrown.
Gate B, the apply gate, is the elif in the else branch:
# add user permission only if role has read perm
elif role_permissions.get("read") or role_permissions.get("select"):
user_permissions = frappe.permissions.get_user_permissions(self.user)
self.add_user_permissions(user_permissions)
The fourth clause of Gate A was written as an escape hatch: this user has User Permissions on this doctype, so do not hard-deny them — the User Permission machinery downstream will restrict them. That assumption only holds when Gate B also fires. Gate B is guarded by read or select, which is precisely what the scenario lacks. So the escape hatch disables the deny path without enabling the restrict path. The comment on the elif is the policy statement that opens the hole.
Choose a role grant and whether User Permission rows exist, then step through all eight decision points — five clauses of the deny gate, the owner-constraint check, the apply gate, the return assembly, and the ERPNext caller — watching the return value resolve and the caller's decision follow from it.
not self.doctype_meta.istableWe are asking about a normal master doctype (Customer, Company, Territory), not a child table. Clause holds.
Control flow traced from frappe/model/db_query.py on version-15, version-16 and develop — the function is functionally identical on all three. On version-16 and develop the standard report callers no longer reach it; see the exposure checker for what your version actually does.
The full truth table, re-derived from the source:
Role read/select | User Permissions on doctype | Result | Correct? |
|---|---|---|---|
| yes | yes | SQL with name in (…) | correct |
| yes | no | "" — genuinely unrestricted | correct |
| no | no | PermissionError (unless shared) | correct |
| no | yes | "" / [] | fail-open |
Three of four cells are right. The fourth returns the same value as the second, and no caller can tell them apart.
Why an empty string means "let everything through"
build_match_conditions(as_condition=True) returns a raw SQL fragment, and the caller contract is string truthiness. The canonical ERPNext shape, from erpnext/accounts/report/general_ledger/general_ledger.py on version-15:
from frappe.desk.reportview import build_match_conditions
match_conditions = build_match_conditions("GL Entry")
if match_conditions:
conditions.append(match_conditions)
There is no sentinel for "denied". An empty string is indistinguishable from "Administrator, or a user with no User Permissions, nothing to restrict". On develop the same code has been refactored to the walrus form — if match_conditions := build_match_conditions("GL Entry"): — which is the identical test.
The as_condition=False variant has the same problem with an empty list. frappe/desk/query_report.py::get_user_match_filters builds a filter map only from truthy entries, and then get_filtered_data closes the loop:
if match_filters_per_doctype:
for row in data:
...
else:
result = list(data) # every row, unfiltered
return result
else: result = list(data) is the literal fail-open. An empty filter set means return the entire result set. That is why the leak on version-15 also covers custom script reports you wrote yourself — including ones that never call build_match_conditions at all, because the framework's own post-filter is the thing that goes no-op.
The version-15 call sites
Thirteen call sites in ERPNext version-15 use the vulnerable if match_conditions: shape. Naming them, because these are the reports where a restricted user would actually see the extra rows:
- General Ledger —
accounts/report/general_ledger/general_ledger.py - Sales Register and Purchase Register —
accounts/report/sales_register/,accounts/report/purchase_register/ - Item-wise Sales Register and Item-wise Purchase Register
- Customer/Supplier Ledger Summary —
accounts/report/customer_ledger_summary/ - Balance Sheet, Profit and Loss, Trial Balance — all via
accounts/report/financial_statements.py - Daily Timesheet Summary —
projects/report/daily_timesheet_summary/ - Sales Person-wise Transaction Summary —
selling/report/sales_person_wise_transaction_summary/ projects/utils.py(task query util) andaccounts/utils.py::build_qb_match_conditionsstock/dashboard/item_dashboard.pyandstock/dashboard/warehouse_capacity_dashboard.py— these two call the legacyDatabaseQuerydirectly
I verified the call sites and the code pattern. I did not run each report as a restricted user to confirm which columns leak in practice — describe the mechanism to your team, not per-report specifics.
What is not affected
This distinction matters, because it is the reason the misconfiguration survives so long unnoticed.
List views are not exposed. frappe.get_list and reportview.get() go through DatabaseQuery.execute(), which calls check_read_permission() → frappe.has_permission() before any match conditions are built. A user with no read or select on Customer gets a hard permission error opening the Customer list, regardless of User Permissions. Same for the Report Builder view of a doctype, and same for /api/resource/<doctype>, which is the same get_list path.
So the accurate framing is: the row filter that reports rely on degrades to a no-op, while the doctype-level gate that list views rely on still holds. Everything looks correctly locked down everywhere you would think to check.
The report itself is gated on a different checkbox — frappe.has_permission(report.ref_doctype, "report"). If Report is ticked and Read is not, you have built exactly the seam this bug lives in.
The v16 half of the story
Rerouting to an engine that fails closed solved the leak and created the mirror-image problem. When a report has a Link column pointing at a doctype the user cannot read, the new engine raises PermissionError: No permission to read <DocType> and the whole report dies. PR #40124, still open, is an attempt to soften that — the example in it is Stock Ledger Variance, whose voucher_type column links to DocType itself. Issue #41286, also open, reports the user-facing version on frappe 16.28.0: a script report with a Link filter to a doctype where the user only has select throws a permission message while the data still renders.
There is also a third data point worth knowing: issue #39863 was a different fail-open introduced by the same v16 migration — the new engine returned per-doctype filter dicts in a shape that flipped AND to OR across link fields. That one was fixed by PR #40135 in June 2026.
Neither engine got the four-cell matrix right on the first attempt. And frappe/tests/test_db_query.py::test_build_match_conditions still asserts the empty string as correct:
# Before any user permission is applied
self.assertEqual(build_match_conditions(as_condition=False), [])
self.assertEqual(build_match_conditions(as_condition=True), "")
That assertion is correct for the cell it covers. The problem is that the User-Permissions-present, no-read cell has no test at all. A four-cell matrix tested in two cells is how a regression like this stays invisible for years.
Audit, harden, verify
Three tabs of real Python. Audit: a bench console script that lists every user and doctype pair sitting in the fail-open cell, plus the grep that finds vulnerable call sites in your apps. Harden: a fail-closed wrapper to use instead of build_match_conditions. Verify: the assert-non-empty test to keep in CI.
Two passes. First, list every user/doctype combination that sits in the fail-open cell — this uses Frappe's own get_role_permissions, so Custom DocPerm overrides, role profiles and if_owner are all resolved correctly. Second, grep your apps for the caller shape that silently drops the filter.
# Pass 1 — bench --site <site> console
# Which user/doctype pairs hold User Permissions while
# their roles grant neither read nor select?
import frappe
from frappe.permissions import get_role_permissions, get_user_permissions
def audit_fail_open():
findings = []
users = frappe.get_all("User Permission", distinct=True, pluck="user")
for user in users:
if user == "Administrator":
continue
ups = get_user_permissions(user) or {}
for doctype in ups:
if not frappe.db.exists("DocType", doctype):
continue
if frappe.get_meta(doctype).istable:
continue
perms = get_role_permissions(doctype, user=user)
if perms.get("read") or perms.get("select"):
continue
if perms.get("has_if_owner_enabled"):
continue # owner-constraint branch, not this bug
findings.append((user, doctype, len(ups[doctype])))
return findings
for user, doctype, n in sorted(audit_fail_open()):
print(f"FAIL-OPEN user={user:35s} doctype={doctype:25s} user_permissions={n}")
# Pass 2 — from frappe-bench/apps, find every caller
#
# grep -rn --include='*.py' \
# -e 'build_match_conditions' \
# -e 'get_match_cond' \
# . | grep -v '/frappe/tests/'
#
# Then isolate the vulnerable shape — an "if match:" with no else:
#
# grep -rn --include='*.py' -A3 'build_match_conditions' . \
# | grep -E 'if (match|.*:=)' -A1
#
# Anything of the form
# if match_conditions:
# or if match_conditions := build_match_conditions("GL Entry"):
# with no else branch drops the filter when the function
# returns "". Route it through safe_match_conditions().These snippets are written from the verified source on frappe version-15, version-16 and develop HEAD. They were not execution-tested against a running bench — read them before you run them, and run the audit pass in a read-only console first.
Record your versions
bench version. Write down the frappe major version, not just the ERPNext one — the behaviour fork is in frappe. Issue #41270 was reported against frappe 15.116.0 / erpnext 15.118.1 on Frappe Cloud. I verified the version-15 branch HEAD, not any specific released point version in between.
Run the audit read-only
Use the Python audit in the recipe's first tab, in bench --site <site> console. It uses Frappe's own get_role_permissions, so Custom DocPerm overrides, role profiles, permlevels and if_owner are all resolved the way the framework resolves them. An empty result is the outcome you want. Save whatever it prints — that is your remediation list.
Grep your apps for the caller shape
From frappe-bench/apps, search for build_match_conditions and get_match_cond. Every hit of the form if match_conditions: with no else branch is a report that drops the filter silently. Also look for direct imports of DatabaseQuery from frappe.model.db_query — that is the one that fails open on every version.
Grant select, deliberately
For every doctype in the audit output, tick Select for the affected role in the Role Permissions Manager. select is the minimal grant that satisfies the apply gate, so add_user_permissions() actually runs, without granting list-view read. Be honest that this is a permission change — those values become selectable in link fields for that role. Review it per doctype; do not blanket-apply it with a patch and walk away.
Wrap match conditions in your own reports
Deploy safe_match_conditions() from the recipe's second tab and call it everywhere your own code used build_match_conditions. It turns the ambiguous empty return into an explicit decision: throw, or emit 1=0 so the report renders with zero rows instead of erroring. While you are there, audit your permission_query_conditions hooks — they are ANDed onto the same expression and have the identical failure mode. A hook returning "" for an unrecognised user is a second, independent fail-open.
Keep the test
Add the assert-non-empty test from the third tab to your app's suite. It skips the cells that are legitimately empty and only fails on the fail-open cell, and it catches PermissionError so it also passes on version-16 and develop, where raising is the correct answer.
Run it on your own instance
Nine checks covering version, the fail-open audit, the report-versus-read permission seam, granting select, grepping your call sites, direct legacy callers, permission_query_conditions hooks, CI coverage, and a live spot-check as the restricted user.
Nothing here is saved and nothing here touches your instance — it is a checklist for your own run-through. Issue frappe/frappe#41270 is still open with no linked fix, so re-run the audit after every bench update.
If you build reports regularly, the broader lesson is worth internalising alongside the ERPNext dashboards and reports guide: a hand-written frappe.db.sql in a script report opts you out of the permission-checked path entirely, and then you are relying on a post-filter you did not write and probably have not read. Preferring frappe.get_list or frappe.qb.get_query(..., ignore_permissions=False) in new reports is the durable fix.
+Why do ERPNext reports show data outside a user's User Permissions?
Because the report row filter comes from build_match_conditions(), and on Frappe version-15 that function returns an empty SQL string when the user holds User Permissions on a doctype but their roles grant neither read nor select on it. Callers treat the empty string as "no restriction", so the filter is dropped, and get_filtered_data() falls through to result = list(data) and returns every row. It is tracked as frappe/frappe issue 41270, which is still open.
+Which Frappe versions are affected by the build_match_conditions fail-open?
The buggy function is unchanged on version-15, version-16 and develop as of 5 August 2026. The practical exposure through the standard report path is version-15. On version-16 and develop, PR #35857 (merged 3 February 2026, backported by PR #36618) rerouted the report and list callers to frappe/database/query.py::Engine, which fails closed instead — so v16 users are not leaking rows through reports. What v16 users get from the same misconfiguration is the opposite failure: reports that abort with a permission error or render empty. Custom code that calls frappe.model.db_query.DatabaseQuery(...).build_match_conditions() directly is still exposed on every version.
+Why does the list view respect User Permissions but the report does not?
Different code paths. List views go through DatabaseQuery.execute() → check_read_permission() → frappe.has_permission(), which hard-denies before match conditions are ever built. Reports are gated on the separate report permission of the report's ref_doctype, and then rely on the match-condition row filter — which is the thing that goes no-op. If Report is ticked and Read is not, you have created the seam.
+Hey Google, why can my ERPNext user see other companies' data in the general ledger?
Most likely because that user has User Permissions on Company but their role has no read or select permission on Company. On Frappe version-15 that combination makes the General Ledger report's permission filter come back empty, so erpnext/accounts/report/general_ledger/general_ledger.py appends nothing and the query returns every company's entries. Open the Role Permissions Manager, tick Select on Company for that role, and re-run the report. If you are on version-16, this is not the cause — check the report's Link columns instead.
+Is it a security bug if Frappe reports ignore user permissions?
It is a genuine data-exposure bug — frappe/frappe issue 41270, still open — but a bounded one, and it is not a CVE or a scored vulnerability. It widens which rows a user sees inside a report they were already allowed to open. It does not grant access to doctypes they could not otherwise reach, does not affect list views or the REST API, and does not allow writing or deleting anything. Treat it as a permission-enforcement defect to audit, not an incident.
+How do I check whether my ERPNext instance is affected?
Run the read-only console script in the recipe above. For each user holding User Permissions, it compares get_role_permissions(doctype, user) against those permissions. Any combination with User Permission rows present and both read and select absent — and no if_owner DocPerm — sits in the fail-open cell. Then confirm your frappe major version, because on version-16 that same combination fails closed rather than open.
+What is the fix for reports ignoring User Permissions in ERPNext?
Grant select at minimum on every doctype that carries User Permissions, in the Role Permissions Manager. That satisfies the role_permissions.get("read") or role_permissions.get("select") gate so add_user_permissions() actually runs. For custom reports, wrap build_match_conditions() so an empty return for a restricted user throws or emits 1=0 instead of nothing. There is no upstream patch to wait for — issue 41270 has no linked PR.
+What happens if we don't fix this?
On version-15, restricted users keep seeing rows in General Ledger, the sales and purchase registers, the ledger summaries and the financial statements that their User Permissions were meant to hide — silently, with nothing in the UI to indicate it. Because it is silent, the usual way it surfaces is someone mentioning a number they should not have known. On version-16 the cost of doing nothing is different but not zero: reports that abort or come back empty, which your users will report as "the report is broken" and your team will spend an afternoon chasing.
The uncomfortable part of this one is not the missing filter — it is that the more restrictive configuration is the one that leaks, and that the symptom class has been reported on the forum continuously since 2018 without a canonical answer. Find your version, run the audit, grant select where the audit says to, and wrap the match condition in any report you wrote yourself. Then keep the test, because issue 41270 is still open and nothing upstream is going to tell you when that changes.
Not sure whether your ERPNext permissions actually hold?
We audit Frappe and ERPNext permission models — role permissions, User Permissions, report seams and custom query reports — and hand you the findings with the remediation, not a PDF. If you are on v15 with hand-tuned role permissions, this is worth an hour.