From d48b0d163203c8b9f3612188bb1f4b9d78eca349 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:09:44 +0530 Subject: [PATCH 1/2] fix(search)!: validate `ignore_user_permissions` in link search --- frappe/core/doctype/docfield/docfield.json | 3 +- .../user_permission/user_permission.json | 7 +- frappe/database/query.py | 7 +- frappe/desk/search.py | 93 ++++++++- frappe/model/qb_query.py | 4 + frappe/public/js/frappe/form/controls/link.js | 2 + .../frappe/form/controls/table_multiselect.js | 8 + frappe/tests/test_search.py | 193 ++++++++++++++++++ 8 files changed, 300 insertions(+), 17 deletions(-) diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index df8e633301..92df52d0aa 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -329,6 +329,7 @@ }, { "default": "0", + "depends_on": "eval:[\"Link\", \"Dynamic Link\", \"Table MultiSelect\"].includes(doc.fieldtype)", "fieldname": "ignore_user_permissions", "fieldtype": "Check", "label": "Ignore User Permissions" @@ -623,7 +624,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-10-14 14:22:21.517289", + "modified": "2025-12-01 06:56:29.335491", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/core/doctype/user_permission/user_permission.json b/frappe/core/doctype/user_permission/user_permission.json index 197972261d..bfd465b6a0 100644 --- a/frappe/core/doctype/user_permission/user_permission.json +++ b/frappe/core/doctype/user_permission/user_permission.json @@ -40,7 +40,6 @@ { "fieldname": "for_value", "fieldtype": "Dynamic Link", - "ignore_user_permissions": 1, "in_list_view": 1, "in_standard_filter": 1, "label": "For Value", @@ -89,8 +88,9 @@ "fieldtype": "Column Break" } ], + "grid_page_length": 50, "links": [], - "modified": "2024-03-23 16:04:00.823875", + "modified": "2025-12-01 06:55:21.174845", "modified_by": "Administrator", "module": "Core", "name": "User Permission", @@ -109,9 +109,10 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "title_field": "user", "track_changes": 1 -} \ No newline at end of file +} diff --git a/frappe/database/query.py b/frappe/database/query.py index 221d86f6a0..ed5c3ed781 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -173,6 +173,7 @@ class Engine: skip_locked: bool = False, wait: bool = True, ignore_permissions: bool = True, + ignore_user_permissions: bool = False, user: str | None = None, parent_doctype: str | None = None, reference_doctype: str | None = None, @@ -184,6 +185,8 @@ class Engine: Args: db_query_compat: When True, uses legacy db_query behavior for sorting and filtering. This is kept optional to not break existing code that relies on the original query builder behaviour. + ignore_user_permissions: Ignore user permissions for the query. + Useful for link search queries when the link field has `ignore_user_permissions` set. """ qb = frappe.local.qb @@ -197,6 +200,7 @@ class Engine: self.parent_doctype = parent_doctype self.reference_doctype = reference_doctype self.apply_permissions = not ignore_permissions + self.ignore_user_permissions = ignore_user_permissions self.function_aliases = set() self.field_aliases = set() self.db_query_compat = db_query_compat @@ -1296,8 +1300,7 @@ class Engine: conditions = [] fetch_shared_docs = False - # add user permission only if role has read perm - if not (role_permissions.get("read") or role_permissions.get("select")): + if self.ignore_user_permissions: return conditions, fetch_shared_docs user_permissions = frappe.permissions.get_user_permissions(self.user) diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 724305f065..61be6c28ee 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -10,11 +10,11 @@ from typing_extensions import NotRequired # not required in 3.11+ import frappe # Backward compatbility -from frappe import _, is_whitelisted, validate_and_sanitize_search_inputs +from frappe import _, bold, is_whitelisted, validate_and_sanitize_search_inputs from frappe.database.schema import SPECIAL_CHAR_PATTERN from frappe.model.db_query import get_order_by from frappe.permissions import has_permission -from frappe.utils import cint, cstr, unique +from frappe.utils import cint, cstr, escape_html, unique from frappe.utils.data import make_filter_tuple @@ -43,6 +43,9 @@ def search_link( searchfield: str | None = None, reference_doctype: str | None = None, ignore_user_permissions: bool = False, + *, + form_doctype: str | None = None, + link_fieldname: str | None = None, ) -> list[LinkSearchResults]: results = search_widget( doctype, @@ -53,6 +56,8 @@ def search_link( filters=filters, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions, + form_doctype=form_doctype, + link_fieldname=link_fieldname, ) return build_for_autosuggest(results, doctype=doctype) @@ -71,6 +76,9 @@ def search_widget( as_dict: bool = False, reference_doctype: str | None = None, ignore_user_permissions: bool = False, + *, + form_doctype: str | None = None, + link_fieldname: str | None = None, ): start = cint(start) @@ -102,6 +110,8 @@ def search_widget( as_dict=as_dict, reference_doctype=reference_doctype, ignore_user_permissions=ignore_user_permissions, + form_doctype=form_doctype, + link_fieldname=link_fieldname, ) except (frappe.PermissionError, frappe.AppNotInstalledError, ImportError): if frappe.local.conf.developer_mode: @@ -189,14 +199,17 @@ def search_widget( # Since we are sorting by alias postgres needs to know number of column we are sorting order_by = f"{len(formatted_fields)} desc nulls last, {order_by}" - ignore_permissions = doctype == "DocType" or ( - cint(ignore_user_permissions) - and has_permission( - doctype, - ptype="select" if frappe.only_has_select_perm(doctype) else "read", - parent_doctype=reference_doctype, - ) - ) + if ignore_user_permissions: + if form_doctype and link_fieldname: + validate_ignore_user_permissions(form_doctype, link_fieldname, doctype) + else: + frappe.logger().error( + "setting ignore_user_permissions=True in search_link requires " + "form_doctype and link_fieldname to be set. " + f"Got form_doctype={form_doctype}, link_fieldname={link_fieldname}. " + "Ignoring flag." + ) + ignore_user_permissions = False values = frappe.get_list( doctype, @@ -206,7 +219,8 @@ def search_widget( limit_start=start, limit_page_length=None if meta.translated_doctype else page_length, order_by=order_by, - ignore_permissions=ignore_permissions, + ignore_permissions=doctype == "DocType", + ignore_user_permissions=ignore_user_permissions, reference_doctype=reference_doctype, as_list=not as_dict, strict=False, @@ -239,6 +253,63 @@ def search_widget( return values +def validate_ignore_user_permissions(form_doctype, link_fieldname, link_doctype): + def _throw(message): + frappe.throw(message, title=_('Error validating "Ignore User Permissions"')) + + meta = frappe.get_meta(form_doctype) + link_field = meta.get_field(link_fieldname) + + if not link_field: + _throw( + _("Field {0} not found in {1}").format( + escape_html(link_fieldname), bold(_(form_doctype)) + ) + ) + + ignore_user_permissions = link_field.ignore_user_permissions + found_doctype = None + + if link_field.fieldtype == "Link": + found_doctype = link_field.options + + if link_field.fieldtype == "Table MultiSelect": + child_meta = frappe.get_meta(link_field.options) + child_link_field = next((field for field in child_meta.fields if field.fieldtype == "Link"), None) + if not child_link_field: + _throw( + _( + "Table MultiSelect requires a table with at least one Link field, but none was found in {0}" + ).format(bold(_(link_field.options))) + ) + + found_doctype = child_link_field.options + if not ignore_user_permissions: + # ignore user permissions should be set in parent table field + # or in child table link field + ignore_user_permissions = child_link_field.ignore_user_permissions + + if not ignore_user_permissions: + _throw( + _("The field {0} in {1} does not allow ignoring user permissions").format( + bold(meta.get_label(link_fieldname)), bold(_(form_doctype)) + ) + ) + + if link_field.fieldtype == "Dynamic Link": + return # skip doctype check for Dynamic Link fields + + if found_doctype != link_doctype: + _throw( + _("The field {0} in {1} links to {2} and not {3}").format( + bold(meta.get_label(link_fieldname)), + bold(_(form_doctype)), + bold(_(found_doctype)), + bold(escape_html(link_doctype)), + ) + ) + + def get_std_fields_list(meta, key): # get additional search fields sflist = ["name"] diff --git a/frappe/model/qb_query.py b/frappe/model/qb_query.py index 035526843a..882fcc531f 100644 --- a/frappe/model/qb_query.py +++ b/frappe/model/qb_query.py @@ -55,6 +55,7 @@ class DatabaseQuery: ignore_ddl: bool = False, *, parent_doctype: str | None = None, + ignore_user_permissions: bool = False, ) -> list: """Execute a database query using the Query Builder engine. @@ -89,6 +90,8 @@ class DatabaseQuery: pluck: Extract single field values as a simple list. ignore_ddl: Ignore DDL operations during query execution (legacy compatibility). parent_doctype: Parent doctype for child table queries. + ignore_user_permissions: Ignore user permissions for the query. + Useful for link search queries when the link field has `ignore_user_permissions` set. Returns: Query results as list of dicts (default) or list of lists (as_list=True). @@ -186,6 +189,7 @@ class DatabaseQuery: "offset": frappe.cint(offset), "distinct": distinct, "ignore_permissions": ignore_permissions, + "ignore_user_permissions": ignore_user_permissions, "user": user, "parent_doctype": parent_doctype, "reference_doctype": reference_doctype, diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 4bc0279b76..e14ed63aa6 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -280,6 +280,8 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat ignore_user_permissions: me.df.ignore_user_permissions, reference_doctype: me.get_reference_doctype() || "", page_length: cint(frappe.boot.sysdefaults?.link_field_results_limit) || 10, + form_doctype: me.doctype, + link_fieldname: me.df.fieldname, }; me.set_custom_query(args); diff --git a/frappe/public/js/frappe/form/controls/table_multiselect.js b/frappe/public/js/frappe/form/controls/table_multiselect.js index fa07c10ecf..44b4ef4325 100644 --- a/frappe/public/js/frappe/form/controls/table_multiselect.js +++ b/frappe/public/js/frappe/form/controls/table_multiselect.js @@ -2,6 +2,14 @@ frappe.ui.form.ControlTableMultiSelect = class ControlTableMultiSelect extends ( frappe.ui.form.ControlLink ) { static horizontal = false; + make() { + // parent element + super.make(); + const link_field = this.get_link_field(); + if (link_field?.ignore_user_permissions) { + this.df.ignore_user_permissions = true; + } + } make_input() { super.make_input(); this.$input_area.addClass("form-control table-multiselect"); diff --git a/frappe/tests/test_search.py b/frappe/tests/test_search.py index a08a885b93..6689fc400d 100644 --- a/frappe/tests/test_search.py +++ b/frappe/tests/test_search.py @@ -6,7 +6,9 @@ from contextlib import contextmanager from functools import partial import frappe +from frappe.core.doctype.doctype.test_doctype import new_doctype from frappe.desk.search import get_names_for_mentions, search_link, search_widget +from frappe.permissions import add_user_permission from frappe.tests import IntegrationTestCase from frappe.tests.utils import whitelist_for_tests @@ -181,6 +183,197 @@ class TestSearch(IntegrationTestCase): result = search(txt="(txt)") self.assertEqual(result, []) + def test_search_link_with_ignore_user_permissions(self): + """Test that ignore_user_permissions works correctly in search_link + when the link field has ignore_user_permissions enabled""" + + # Clean up any leftover doctypes from previous test runs + for dt in ("Test Search Form", "Test Search Linked"): + if frappe.db.exists("DocType", dt): + frappe.delete_doc("DocType", dt, force=True) + + # Create a test doctype to link to + new_doctype( + name="Test Search Linked", + fields=[{"label": "Title", "fieldname": "title", "fieldtype": "Data"}], + permissions=[{"role": "System Manager", "read": 1, "write": 1}], + search_fields="title", + ).insert() + + # Create a form doctype with a link field that has ignore_user_permissions + new_doctype( + name="Test Search Form", + fields=[ + { + "label": "Linked Doc", + "fieldname": "linked_doc", + "fieldtype": "Link", + "options": "Test Search Linked", + "ignore_user_permissions": 1, + } + ], + permissions=[{"role": "System Manager", "read": 1, "write": 1}], + ).insert() + + self.addCleanup( + lambda: frappe.delete_doc("DocType", "Test Search Form", force=True, ignore_missing=True) + ) + self.addCleanup(lambda: frappe.delete_doc("DocType", "Test Search Linked", force=True)) + + # Create some test documents + allowed_doc = frappe.get_doc({"doctype": "Test Search Linked", "title": "Allowed Document"}).insert() + restricted_doc = frappe.get_doc( + {"doctype": "Test Search Linked", "title": "Restricted Document"} + ).insert() + self.addCleanup(lambda: frappe.delete_doc("Test Search Linked", allowed_doc.name, force=True)) + self.addCleanup(lambda: frappe.delete_doc("Test Search Linked", restricted_doc.name, force=True)) + + # Create a test user with restricted permissions + test_user = "test_search_user@example.com" + if not frappe.db.exists("User", test_user): + user = frappe.get_doc( + { + "doctype": "User", + "email": test_user, + "first_name": "Test Search User", + "user_type": "System User", + } + ).insert(ignore_permissions=True) + user.add_roles("System Manager") + self.addCleanup(lambda: frappe.delete_doc("User", test_user, force=True)) + + # Add user permission to restrict the user to only allowed_doc + add_user_permission("Test Search Linked", allowed_doc.name, test_user) + self.addCleanup( + lambda: frappe.db.delete("User Permission", {"user": test_user, "allow": "Test Search Linked"}) + ) + + frappe.set_user(test_user) + self.addCleanup(lambda: frappe.set_user("Administrator")) + + # Without ignore_user_permissions, only allowed_doc should be returned + results_without_ignore = search_link( + doctype="Test Search Linked", + txt="Document", + ignore_user_permissions=False, + ) + result_values = [r["value"] for r in results_without_ignore] + self.assertIn(allowed_doc.name, result_values) + self.assertNotIn(restricted_doc.name, result_values) + + # With ignore_user_permissions + form_doctype + link_fieldname, both should be returned + results_with_ignore = search_link( + doctype="Test Search Linked", + txt="Document", + ignore_user_permissions=True, + form_doctype="Test Search Form", + link_fieldname="linked_doc", + ) + result_values = [r["value"] for r in results_with_ignore] + self.assertIn(allowed_doc.name, result_values) + self.assertIn(restricted_doc.name, result_values) + + # With ignore_user_permissions=True but WITHOUT form_doctype/link_fieldname, + # the flag should be silently ignored and user permissions should apply + results_without_context = search_link( + doctype="Test Search Linked", + txt="Document", + ignore_user_permissions=True, + # form_doctype and link_fieldname not provided + ) + result_values = [r["value"] for r in results_without_context] + self.assertIn(allowed_doc.name, result_values) + self.assertNotIn(restricted_doc.name, result_values) + + def test_search_link_ignore_user_permissions_validation(self): + """Test that ignore_user_permissions is validated correctly""" + + # Clean up any leftover doctypes from previous test runs + for dt in ("Test Search Form No Ignore", "Test Search Form Wrong Link", "Test Search Linked2"): + if frappe.db.exists("DocType", dt): + frappe.delete_doc("DocType", dt, force=True) + + # Create doctypes for testing + new_doctype( + name="Test Search Linked2", + fields=[{"label": "Title", "fieldname": "title", "fieldtype": "Data"}], + ).insert() + + # Form with link field WITHOUT ignore_user_permissions + new_doctype( + name="Test Search Form No Ignore", + fields=[ + { + "label": "Linked Doc", + "fieldname": "linked_doc", + "fieldtype": "Link", + "options": "Test Search Linked2", + "ignore_user_permissions": 0, + } + ], + ).insert() + + self.addCleanup( + lambda: frappe.delete_doc( + "DocType", "Test Search Form No Ignore", force=True, ignore_missing=True + ) + ) + self.addCleanup( + lambda: frappe.delete_doc( + "DocType", "Test Search Form Wrong Link", force=True, ignore_missing=True + ) + ) + self.addCleanup( + lambda: frappe.delete_doc("DocType", "Test Search Linked2", force=True, ignore_missing=True) + ) + + # Should throw when field does not have ignore_user_permissions + self.assertRaises( + frappe.ValidationError, + search_link, + doctype="Test Search Linked2", + txt="test", + ignore_user_permissions=True, + form_doctype="Test Search Form No Ignore", + link_fieldname="linked_doc", + ) + + # Should throw when field doesn't exist + self.assertRaises( + frappe.ValidationError, + search_link, + doctype="Test Search Linked2", + txt="test", + ignore_user_permissions=True, + form_doctype="Test Search Form No Ignore", + link_fieldname="nonexistent_field", + ) + + # Should throw when doctype doesn't match + new_doctype( + name="Test Search Form Wrong Link", + fields=[ + { + "label": "Wrong Link", + "fieldname": "wrong_link", + "fieldtype": "Link", + "options": "User", # Different doctype + "ignore_user_permissions": 1, + } + ], + ).insert() + self.addCleanup(lambda: frappe.delete_doc("DocType", "Test Search Form Wrong Link", force=True)) + + self.assertRaises( + frappe.ValidationError, + search_link, + doctype="Test Search Linked2", + txt="test", + ignore_user_permissions=True, + form_doctype="Test Search Form Wrong Link", + link_fieldname="wrong_link", + ) + @frappe.validate_and_sanitize_search_inputs def get_data(doctype, txt, searchfield, start, page_len, filters): From 9623b6f4cfd4fd36923145a386a9efc6109a7306 Mon Sep 17 00:00:00 2001 From: Sagar Vora <16315650+sagarvora@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:45:04 +0530 Subject: [PATCH 2/2] test: increase threshold for idle CPU usage to reduce flakiness --- frappe/commands/test_commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py index a7974e5b61..7cdf26b1b4 100644 --- a/frappe/commands/test_commands.py +++ b/frappe/commands/test_commands.py @@ -1108,12 +1108,12 @@ class TestGunicornWorker(IntegrationTestCase): return sum(c.cpu_percent(1.0) for c in process.children(True)) + process.cpu_percent(1.0) self.spawn_gunicorn(["--threads=2"]) - self.assertLessEqual(get_total_usage(), 2) + self.assertLessEqual(get_total_usage(), 3) # Wake up at least one thread, go idle and check again path = f"http://{self.TEST_SITE}:{self.port}/api/method/ping" self.assertEqual(requests.get(path).status_code, 200) - self.assertLessEqual(get_total_usage(), 2) + self.assertLessEqual(get_total_usage(), 3) class TestRQWorker(IntegrationTestCase):