Deep Dive into User Permissions and Document Linking in Frappe
At RUKN, we're planing to develop a new features for the "Branchy" to add more flexibility on the current permissions system. Traditionally, Frappe's user permissions are based solely on linked doctypes and this is a greate idea But not always enough for users. We're addressing this by introducing value-based permissions, allowing for more granular control. For example, users can be granted access to view only invoices below a certain value, such as 10K, or restricted to viewing draft invoices. This enhancement will provide a more versatile and dynamic permission system, catering to various business needs.
To achive this new feature we dive deeply in the frappe framework to explore best proper way to achive it.
Analyze Frappe Permissions
In Frappe, managing permissions and document relationships are fundamental aspects of the system that ensure users interact with records securely and consistently. Two important files that govern these functionalities are:
This article will explore both files in detail and explain how Frappe uses them to enforce permissions and handle document linking.
user_permission.py: Managing User Permissions in Frappe
The User Permission Doctype in Frappe allows administrators to restrict users’ access to specific records within a Doctype. These permissions work alongside role-based permissions to provide granular control over what each user can see and edit.
Key Functions in user_permission.py
1- get_user_permissions : frappe/frappe/permissions.py at develop · frappe/frappe
def get_user_permissions(user=None):
"""Get all users permissions for the user as a dict of doctype"""
# if this is called from client-side,
# user can access only his/her user permissions
if frappe.request and frappe.local.form_dict.cmd == "get_user_permissions":
user = frappe.session.user
if not user:
user = frappe.session.user
if not user or user in ("Administrator", "Guest"):
return {}
cached_user_permissions = frappe.cache().hget("user_permissions", user)
if cached_user_permissions is not None:
return cached_user_permissions
out = {}
def add_doc_to_perm(perm, doc_name, is_default):
# group rules for each type
# for example if allow is "Customer", then build all allowed customers
# in a list
if not out.get(perm.allow):
out[perm.allow] = []
out[perm.allow].append(
frappe._dict(
{"doc": doc_name, "applicable_for": perm.get("applicable_for"), "is_default": is_default}
)
)
try:
for perm in frappe.get_all(
"User Permission",
fields=["allow", "for_value", "applicable_for", "is_default", "hide_descendants"],
filters=dict(user=user),
):
meta = frappe.get_meta(perm.allow)
add_doc_to_perm(perm, perm.for_value, perm.is_default)
if meta.is_nested_set() and not perm.hide_descendants:
decendants = frappe.db.get_descendants(perm.allow, perm.for_value)
for doc in decendants:
add_doc_to_perm(perm, doc, False)
out = frappe._dict(out)
frappe.cache().hset("user_permissions", user, out)
except frappe.db.SQLError as e:
if frappe.db.is_table_missing(e):
# called from patch
pass
return out
2- has_permission: frappe/frappe/permissions.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
def has_permission(doctype, ptype="read", doc=None, verbose=False, user=None,raise_exception=True, *, parent_doctype=None,):
if not user:
user = frappe.session.user
if user == "Administrator":
return True
if ptype == "share" and frappe.get_system_settings("disable_document_sharing"):
return False
if not doc and hasattr(doctype, "doctype"):
# first argument can be doc or doctype
doc = doctype
doctype = doc.doctype
if frappe.is_table(doctype):
return has_child_permission(doctype, ptype, doc, user, raise_exception, parent_doctype)
meta = frappe.get_meta(doctype)
if doc:
if isinstance(doc, str):
doc = frappe.get_doc(meta.name, doc)
perm = get_doc_permissions(doc, user=user, ptype=ptype).get(ptype)
if not perm:
push_perm_check_log(_("User {0} does not have access to this document").format(frappe.bold(user)))
else:
if ptype == "submit" and not cint(meta.is_submittable):
push_perm_check_log(_("Document Type is not submittable"))
return False
if ptype == "import" and not cint(meta.allow_import):
push_perm_check_log(_("Document Type is not importable"))
return False
role_permissions = get_role_permissions(meta, user=user)
perm = role_permissions.get(ptype)
if not perm:
push_perm_check_log(
_("User {0} does not have doctype access via role permission for document {1}").format(
frappe.bold(user), frappe.bold(_(doctype))
)
)
def false_if_not_shared():
if ptype in ("read", "write", "share", "submit", "email", "print"):
rights = ["read" if ptype in ("email", "print") else ptype]
if doc:
doc_name = get_doc_name(doc)
shared = frappe.share.get_shared(
doctype,
user,
rights=rights,
filters=[["share_name", "=", doc_name]],
limit=1,
)
if shared:
if ptype in ("read", "write", "share", "submit") or meta.permissions[0].get(ptype):
return True
elif frappe.share.get_shared(doctype, user, rights=rights, limit=1):
# if atleast one shared doc of that type, then return True
# this is used in db_query to check if permission on DocType
return True
return False
if not perm:
perm = false_if_not_shared()
return bool(perm)
3- clear_permissions_cache: frappe/frappe/core/doctype/doctype/doctype.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
def clear_permissions_cache(doctype):
from frappe.cache_manager import clear_user_cache
frappe.clear_cache(doctype=doctype)
delete_notification_count_for(doctype)
clear_user_cache()
4- get_permission_query_conditions: frappe/frappe/model/db_query.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
def get_permission_query_conditions(self) -> str:
conditions = []
hooks = frappe.get_hooks("permission_query_conditions", {})
condition_methods = hooks.get(self.doctype, []) + hooks.get("*", [])
for method in condition_methods:
if c := frappe.call(frappe.get_attr(method), self.user, doctype=self.doctype):
conditions.append(c)
if permission_script_name := get_server_script_map().get("permission_query", {}).get(self.doctype):
script = frappe.get_doc("Server Script", permission_script_name)
if condition := script.get_permission_query_conditions(self.user):
conditions.append(condition)
return " and ".join(conditions) if conditions else ""
linked_with.py: Managing Document Relationships in Frappe
In Frappe, documents often reference other documents. For example, a Sales Invoice might link to a Sales Order. Managing these relationships is crucial to ensure data consistency, especially when one document is canceled or deleted.
Key Functions in linked_with.py
1- get_linked_docs: frappe/frappe/desk/form/linked_with.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
→ By fetching related documents, the system can enforce rules such as preventing the deletion of records that are still referenced by other documents, or cascading cancelations and updates when required. The function achieves this by scanning for relationships based on document links and returning a structured list of all linked documents, allowing further actions such as validation, cancelation, or deletion to be applied in an informed manner.
def get_linked_docs(doctype: str, name: str, linkinfo: dict | None = None) -> dict[str, list]:
if isinstance(linkinfo, str):
linkinfo = json.loads(linkinfo)
results = {}
if not linkinfo:
return results
is_target_doctype_table = frappe.get_meta(doctype).istable
for linked_doctype, link_context in linkinfo.items():
linked_doctype_meta = frappe.get_meta(linked_doctype)
if linked_doctype_meta.issingle:
continue
filters = []
ret = None
parent_info = None
fields = [
d.fieldname
for d in linked_doctype_meta.get(
"fields",
{
"in_list_view": 1,
"fieldtype": ["not in", ("Image", "HTML", "Button", *frappe.model.table_fields)],
},
)
] + ["name", "modified", "docstatus"]
if add_fields := link_context.get("add_fields"):
fields += add_fields
fields = [f"`tab{linked_doctype}`.`{sf.strip()}`" for sf in fields if sf and "`tab" not in sf]
if filters_ctx := link_context.get("filters"):
ret = frappe.get_list(doctype=linked_doctype, fields=fields, filters=filters_ctx, order_by=None)
elif link_context.get("get_parent"):
# check for child table
if not is_target_doctype_table:
continue
parent_info = parent_info or frappe.db.get_value(
doctype, name, ["parenttype", "parent"], as_dict=True, order_by=None
)
if parent_info and parent_info.parenttype == linked_doctype:
ret = frappe.get_list(
doctype=linked_doctype,
fields=fields,
filters=[[linked_doctype, "name", "=", parent_info.parent]],
order_by=None,
)
elif child_doctype := link_context.get("child_doctype"):
or_filters = [
[child_doctype, link_fieldnames, "=", name] for link_fieldnames in link_context["fieldname"]
]
# dynamic link_context
if doctype_fieldname := link_context.get("doctype_fieldname"):
filters.append([child_doctype, doctype_fieldname, "=", doctype])
ret = frappe.get_list(
doctype=linked_doctype,
fields=fields,
filters=filters,
or_filters=or_filters,
distinct=True,
order_by=None,
)
elif link_fieldnames := link_context.get("fieldname"):
if isinstance(link_fieldnames, str):
link_fieldnames = [link_fieldnames]
or_filters = [[linked_doctype, fieldname, "=", name] for fieldname in link_fieldnames]
# dynamic link_context
if doctype_fieldname := link_context.get("doctype_fieldname"):
filters.append([linked_doctype, doctype_fieldname, "=", doctype])
ret = frappe.get_list(
doctype=linked_doctype, fields=fields, filters=filters, or_filters=or_filters, order_by=None
)
if ret:
results[linked_doctype] = ret
return results
2- get_linked_doctypes: frappe/frappe/desk/form/linked_with.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
→ For example, if you pass a Sales Order Doctype, the function will return a list of Doctypes such as Sales Invoice, Delivery Note, and others that are directly or indirectly linked to Sales Order. This is essential for understanding the web of interdependencies between documents, particularly when managing operations like canceling, deleting, or updating records. Knowing which Doctypes are linked allows the system to enforce rules that prevent data integrity issues, such as ensuring that referenced documents aren't improperly modified or deleted.
@frappe.whitelist()
def get_linked_doctypes(doctype, without_ignore_user_permissions_enabled=False):
if without_ignore_user_permissions_enabled:
return frappe.cache().hget(
"linked_doctypes_without_ignore_user_permissions_enabled",
doctype,
lambda: _get_linked_doctypes(doctype, without_ignore_user_permissions_enabled),
)
else:
return frappe.cache().hget("linked_doctypes", doctype, lambda: _get_linked_doctypes(doctype))
3- cancel_all_linked_docs: frappe/frappe/desk/form/linked_with.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
→ It works by identifying the related documents through their references to the original record, then iterating through them and performing the cancelation action. This prevents issues such as orphaned records (records left without proper associations) or inconsistent data states, ensuring that the system behaves predictably and adheres to business rules regarding linked documents.
@frappe.whitelist()
def cancel_all_linked_docs(docs, ignore_doctypes_on_cancel_all=None):
if ignore_doctypes_on_cancel_all is None:
ignore_doctypes_on_cancel_all = []
docs = json.loads(docs)
if isinstance(ignore_doctypes_on_cancel_all, str):
ignore_doctypes_on_cancel_all = json.loads(ignore_doctypes_on_cancel_all)
for i, doc in enumerate(docs, 1):
if validate_linked_doc(doc, ignore_doctypes_on_cancel_all):
linked_doc = frappe.get_doc(doc.get("doctype"), doc.get("name"))
linked_doc.cancel()
frappe.publish_progress(percent=i / len(docs) * 100, title=_("Cancelling documents"))
4- validate_linked_doc: frappe/frappe/desk/form/linked_with.py at 64ec68ff00811905fb750bbd6f81be49fdc8b86c · frappe/frappe
→ Is not already canceled.
→ Is a submittable document (i.e., a document that has a workflow requiring submission and approval).
领英推荐
→ Is not exempt from auto-canceling based on system rules.
def validate_linked_doc(docinfo, ignore_doctypes_on_cancel_all=None):
if docinfo.get("doctype") in (ignore_doctypes_on_cancel_all or []):
return False
if not frappe.get_meta(docinfo.get("doctype")).is_submittable:
return False
if docinfo.get("docstatus") != 1:
return False
auto_cancel_exempt_doctypes = get_exempted_doctypes()
if docinfo.get("doctype") in auto_cancel_exempt_doctypes:
return False
return True
In Frappe, user permissions and document linking play an important role in controlling access to records and managing relationships between documents. The files user_permission.py and linked_with.py work together to provide robust functionality for controlling which records users can access and how documents relate to each other.
How user_permission.py and linked_with.py Work Together in Frappe
Here’s a step-by-step explanation of how the two files work together:
Scenario: A User Accessing Linked Documents
Let’s take a practical scenario to understand how these files work together:
1. User Permissions Setup (user_permission.py)
2. Fetching Linked Documents (linked_with.py)
3. Applying User Permissions to Linked Documents (permissions.py)
4. Restricting Query Results (db_query.py)
5. Custom Hooks: permission_query_conditions and has_permission
How permission_query_conditions and has_permission Hooks Fit In
In Frappe, hooks like permission_query_conditions and has_permission play a significant role in customizing how permissions are applied.
permission_query_conditions:
app/hooks.py
permission_query_conditions = {
"ToDo": "app.permissions.todo_query",
}
The method is called with a single argument user which can be None. The method should return a string that is a valid SQL WHERE clause.
app/permissions.py
def todo_query(user):
if not user:
user = frappe.session.user
# todos that belong to user or assigned by user
return "(`tabToDo`.owner = {user} or `tabToDo`.assigned_by = {user})".format(user=frappe.db.escape(user))
Now, if you use the frappe.db.get_list method, your WHERE clause will be appended to the query.
todos = frappe.db.get_list("ToDo", debug=1)
# output
'''
select `tabToDo`.`name`
from `tabToDo`
where ((`tabToDo`.owner = '[email protected]' or `tabToDo`.assigned_by = '[email protected]'))
order by `tabToDo`.`modified` DESC
'''
> This hook will only affect the result of frappe.db.get_list method and not > frappe.db.get_all.
has_permission:
app/hooks.py
has_permission = {
"Event": "app.permissions.event_has_permission",
}
The method will be passed the doc, user and permission_type as arguments. It should return True or a False value. If None is returned, it will fallback to default behavior.
app/permissions.py
def event_has_permission(doc, user=None, permission_type=None):
# when reading a document allow if event is Public
if permission_type == "read" and doc.event_type == "Public":
return True
# when writing a document allow if event owned by user
if permission_type == "write" and doc.owner == user:
return True
return False
Full Sequence Diagram for Permissions and Linked Document Handling
Article written by Husam Hammad reviewed by Alaa Alsalehi