fix: enforce company restrictions at transaction level

Restrict to Companies only filtered list views and document reads, and
only for users with Company user permissions. Any user could still use
a master restricted to Company A in a Company B transaction, and users
without Company user permissions bypassed the feature entirely.

Validate on save of transactions that every linked Item, Customer and
Supplier allows the transaction company, and filter item link queries
by the transaction company so restricted items don't show up in the
item selector.
This commit is contained in:
Mihir Kandoi
2026-07-22 14:13:22 +05:30
parent eafd43769b
commit 88b8ce3888
9 changed files with 178 additions and 7 deletions

View File

@@ -25,6 +25,7 @@ from pypika import Order
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.doctype.company_restriction.company_restriction import get_restriction_criterion
from erpnext.stock.get_item_details import _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
from erpnext.utilities.query import get_filter_conditions_qb
@@ -214,6 +215,7 @@ def item_query(
doctype = "Item"
filters = frappe.parse_json(filters)
company = filters.pop("company", None) if isinstance(filters, dict) else None
if filters and isinstance(filters, dict):
if filters.get("customer") or filters.get("supplier"):
@@ -361,6 +363,9 @@ def item_query(
.offset(start)
)
if company:
query = query.where(get_restriction_criterion("Item", [company]))
return query.run(as_dict=as_dict)

View File

@@ -364,6 +364,26 @@ pre_submit_validation_doctypes = [
"Sales Order",
]
company_restricted_transaction_doctypes = [
"Quotation",
"Sales Order",
"Delivery Note",
"Sales Invoice",
"POS Invoice",
"Material Request",
"Request for Quotation",
"Supplier Quotation",
"Purchase Order",
"Purchase Receipt",
"Purchase Invoice",
"Stock Entry",
"Stock Reconciliation",
"Payment Entry",
"Journal Entry",
"Subcontracting Order",
"Subcontracting Receipt",
]
doc_events = {
"*": {
"validate": [
@@ -377,6 +397,9 @@ doc_events = {
tuple(pre_submit_validation_doctypes): {
"validate": "erpnext.accounts.utils.pre_submit_validation",
},
tuple(company_restricted_transaction_doctypes): {
"validate": "erpnext.stock.doctype.company_restriction.company_restriction.validate_transaction_company",
},
"Stock Entry": {
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",

View File

@@ -91,7 +91,7 @@ erpnext.buying = {
this.frm.set_query("item_code", "items", function () {
if (me.frm.doc.is_subcontracted) {
var filters = { supplier: me.frm.doc.supplier };
var filters = { supplier: me.frm.doc.supplier, company: me.frm.doc.company };
filters["is_stock_item"] = 0;
return {
@@ -101,7 +101,12 @@ erpnext.buying = {
} else {
return {
query: "erpnext.controllers.queries.item_query",
filters: { supplier: me.frm.doc.supplier, is_purchase_item: 1, has_variants: 0 },
filters: {
supplier: me.frm.doc.supplier,
is_purchase_item: 1,
has_variants: 0,
company: me.frm.doc.company,
},
};
}
});

View File

@@ -81,7 +81,12 @@ erpnext.sales_common = {
}
return {
query: "erpnext.controllers.queries.item_query",
filters: { is_sales_item: 1, customer: customer, has_variants: 0 },
filters: {
is_sales_item: 1,
customer: customer,
has_variants: 0,
company: me.frm.doc.company,
},
};
});
}

View File

@@ -1,11 +1,20 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import comma_and
from pypika.terms import Bracket, ExistsCriterion
RESTRICTABLE_MASTER_DOCTYPES = ("Item", "Customer", "Supplier")
class CompanyRestrictionError(frappe.ValidationError):
pass
class CompanyRestriction(Document):
# begin: auto-generated types
@@ -40,6 +49,10 @@ def get_permission_query_conditions(user, doctype=None):
if not allowed_companies:
return None
return get_restriction_criterion(doctype, allowed_companies)
def get_restriction_criterion(doctype, companies):
parent = frappe.qb.DocType(doctype)
restriction = frappe.qb.DocType("Company Restriction")
allowed_rows = (
@@ -49,7 +62,7 @@ def get_permission_query_conditions(user, doctype=None):
(restriction.parenttype == doctype)
& (restriction.parentfield == "allowed_companies")
& (restriction.parent == parent.name)
& (restriction.company.isin(allowed_companies))
& (restriction.company.isin(companies))
)
)
return Bracket((parent.restrict_to_companies == 0) | ExistsCriterion(allowed_rows))
@@ -95,6 +108,68 @@ def validate_allowed_companies(doc):
)
def validate_transaction_company(doc, method=None):
company = doc.get("company")
if not company:
return
for doctype, names in get_master_references(doc).items():
if blocked := get_blocked_masters(doctype, names, company):
frappe.throw(
_("{0} {1} cannot be used with Company {2} because of Company Restrictions").format(
_(doctype),
comma_and([frappe.bold(name) for name in blocked], add_quotes=False),
frappe.bold(company),
),
CompanyRestrictionError,
title=_("Restricted to Other Companies"),
)
def get_master_references(doc):
references = defaultdict(set)
collect_master_references(doc, references)
for table_field in doc.meta.get_table_fields():
for row in doc.get(table_field.fieldname) or []:
collect_master_references(row, references)
return references
def collect_master_references(row, references):
meta = frappe.get_meta(row.doctype)
for field in meta.get_link_fields():
if field.options in RESTRICTABLE_MASTER_DOCTYPES and (value := row.get(field.fieldname)):
references[field.options].add(value)
for field in meta.get_dynamic_link_fields():
doctype = row.get(field.options)
if doctype in RESTRICTABLE_MASTER_DOCTYPES and (value := row.get(field.fieldname)):
references[doctype].add(value)
def get_blocked_masters(doctype, names, company):
restricted = frappe.get_all(
doctype,
filters={"name": ("in", sorted(names)), "restrict_to_companies": 1},
pluck="name",
)
if not restricted:
return []
allowed = frappe.get_all(
"Company Restriction",
filters={
"parenttype": doctype,
"parentfield": "allowed_companies",
"parent": ("in", restricted),
"company": company,
},
pluck="parent",
)
return sorted(set(restricted) - set(allowed))
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def company_query(

View File

@@ -0,0 +1,56 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.selling.doctype.customer.test_customer import make_customer
from erpnext.selling.doctype.quotation.test_quotation import make_quotation
from erpnext.stock.doctype.company_restriction.company_restriction import CompanyRestrictionError
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.tests.utils import ERPNextTestSuite
class TestCompanyRestriction(ERPNextTestSuite):
def restrict_to_companies(self, doctype, name, companies):
doc = frappe.get_doc(doctype, name)
doc.restrict_to_companies = 1
doc.set("allowed_companies", [])
for company in companies:
doc.append("allowed_companies", {"company": company})
doc.save()
def test_restricted_item_blocks_transaction_in_other_company(self):
item = make_item()
self.restrict_to_companies("Item", item.name, ["_Test Company 1"])
self.assertRaises(CompanyRestrictionError, make_material_request, item_code=item.name)
self.restrict_to_companies("Item", item.name, ["_Test Company 1", "_Test Company"])
make_material_request(item_code=item.name)
def test_restricted_customer_blocks_transaction_in_other_company(self):
customer = make_customer("_Test Company Restricted Customer")
self.restrict_to_companies("Customer", customer, ["_Test Company 1"])
self.assertRaises(CompanyRestrictionError, make_quotation, party_name=customer, do_not_submit=1)
self.restrict_to_companies("Customer", customer, ["_Test Company"])
make_quotation(party_name=customer, do_not_submit=1)
def test_restricted_supplier_blocks_transaction_in_other_company(self):
supplier = create_supplier(supplier_name="_Test Company Restricted Supplier")
self.restrict_to_companies("Supplier", supplier.name, ["_Test Company 1"])
self.assertRaises(
CompanyRestrictionError, create_purchase_order, supplier=supplier.name, do_not_submit=1
)
self.restrict_to_companies("Supplier", supplier.name, ["_Test Company"])
create_purchase_order(supplier=supplier.name, do_not_submit=1)
def test_unrestricted_item_is_not_blocked(self):
item = make_item()
make_material_request(item_code=item.name)

View File

@@ -22,9 +22,10 @@ frappe.ui.form.on("Material Request", {
return doc.stock_qty <= doc.ordered_qty ? "green" : "orange";
});
frm.set_query("item_code", "items", function () {
frm.set_query("item_code", "items", function (doc) {
return {
query: "erpnext.controllers.queries.item_query",
filters: { company: doc.company },
};
});
@@ -604,7 +605,7 @@ erpnext.buying.MaterialRequestController = class MaterialRequestController exten
onload() {
this.frm.set_query("item_code", "items", function (doc, cdt, cdn) {
let filters = { is_stock_item: 1 };
let filters = { is_stock_item: 1, company: doc.company };
if (doc.material_request_type == "Customer Provided") {
filters.customer = doc.customer;

View File

@@ -1238,7 +1238,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
};
this.frm.fields_dict.items.grid.get_field("item_code").get_query = function () {
return erpnext.queries.item({ is_stock_item: 1 });
return erpnext.queries.item({ is_stock_item: 1, company: me.frm.doc.company });
};
this.frm.set_query("subcontracting_order", function () {

View File

@@ -22,6 +22,7 @@ frappe.ui.form.on("Stock Reconciliation", {
query: "erpnext.controllers.queries.item_query",
filters: {
is_stock_item: 1,
company: doc.company,
},
};
});