Deep Dive into User Permissions and Document Linking in Frappe
Use Case for User Permissions and Linked Documents

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:

  1. user_permission.py: Manages user-level permissions, dictating which records a user can access.
  2. linked_with.py: Manages document relationships, ensuring proper linkage between records (e.g., Sales Orders linking to Invoices).

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

  • Purpose: Fetches all user permissions for a specific user.
  • Explanation: This function retrieves the permissions a user has for various Doctypes, specifying which records within a Doctype they are allowed to access.
  • Example: If a user is only allowed to view Customer ABC, this function returns that information for the Customer Doctype.

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

  • Purpose: Checks if a user has permission to access a specific document within a Doctype.
  • Explanation: The has_permission function evaluates whether the current user has sufficient permissions to perform certain actions (like read, write, or delete) on a specific document. The function cross-checks the user's role-based permissions as defined in the system, including any restrictions imposed by document-level permissions (such as user permissions or custom rules). It also considers additional conditions like restrictions by field values (if any). If the user does not have the required permissions, the function returns False, preventing access. Otherwise, it returns True, allowing the user to interact with the document.

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

  • Purpose: To clear the cached user permissions, ensuring that recent changes to permissions are immediately applied.
  • Explanation: User permissions are cached in the system to improve performance by avoiding frequent database queries. However, when there are updates to user roles, permissions, or any access-related settings, the cached data becomes outdated. The clear_permissions_cache function removes this cached data, forcing the system to retrieve the most current permissions directly from the database. This ensures that any changes to user permissions are immediately effective, maintaining proper access control based on the latest configurations.

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

  • Purpose: Constructs SQL query conditions to enforce user-specific data access restrictions at the database level.
  • Explanation: The get_permission_query_conditions function dynamically generates SQL conditions to ensure that users can only view records they are permitted to access, based on their roles and custom permission settings. It appends these conditions to database queries, particularly in list or report views, so that only authorized records are displayed to users. -> This function retrieves any applicable permission conditions by calling methods from hooks (defined in Frappe or custom apps) or executing custom server scripts (via Server Script doctype). These conditions are then combined and returned as part of the query. If no conditions apply, it returns an empty string, meaning no restrictions are enforced for that user.

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

  • Purpose: Retrieves all documents that are linked to a specific record.
  • Explanation: The get_linked_docs function is responsible for identifying all documents that reference the given record (for example, fetching all Sales Invoices linked to a Sales Order). This function helps ensure that when any action, such as canceling or deleting a document, is performed, all linked records are properly handled.

→ 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

  • Purpose: Retrieves all Doctypes that are related or linked to a given Doctype.
  • Explanation: The get_linked_doctypes function is responsible for identifying all the other Doctypes that have a relationship or link with the specified Doctype. It helps in mapping out document dependencies within the system.

→ 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

  • Purpose: Cancels all documents that are linked to a specific record.
  • Explanation: The cancel_all_linked_docs function is responsible for canceling all documents that are associated with or linked to a given record. When a document (e.g., a Sales Order) is being canceled, any related documents (such as Delivery Notes or Sales Invoices) must also be handled to maintain data consistency. This function ensures that all such linked documents are processed and canceled as needed.

→ 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

  • Purpose: Ensures that a document is valid for cancelation based on specific conditions.
  • Explanation: The validate_linked_doc function checks if a linked document meets the criteria for cancelation. It ensures that the document:

→ 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.

  • This validation process prevents canceling documents that are in an invalid state or have already been handled. The function works by evaluating several conditions, such as checking the document's docstatus (whether it’s submitted), verifying whether the document type allows submission (is_submittable), and determining if the document is part of a set of doctypes exempt from auto-cancelation rules. If any of these checks fail, the function returns False, blocking the cancelation.

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)

  • You correctly describe how user permissions are established via the User Permission Doctype and managed by user_permission.py.
  • Functions like get_user_permissions retrieve the specific permissions assigned to users (e.g., John being allowed to access only Customer ABC).

2. Fetching Linked Documents (linked_with.py)

  • The explanation of fetching linked documents using get_linked_docs in linked_with.py is accurate. It fetches all documents (like Invoices, Delivery Notes) associated with the Sales Order.

3. Applying User Permissions to Linked Documents (permissions.py)

  • This step outlines how Frappe applies user-specific permissions to the linked documents. You correctly explain how has_permission in permissions.py ensures that John can only view records linked to Customer ABC and not Customer XYZ.

4. Restricting Query Results (db_query.py)

  • You accurately describe how get_permission_query_conditions in db_query.py restricts database query results to only show records John is permitted to access (e.g., only Customer ABC).

5. Custom Hooks: permission_query_conditions and has_permission

  • Your explanation of the permission_query_conditions and has_permission hooks is spot on. These hooks allow for further customization of how permissions are applied in queries and individual document access control.
  • The examples for both hooks are well illustrated, providing insight into how to add custom SQL conditions or enforce specific permission logic for documents.

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:

  • This hook is used to customize how list of records are queried for a DocType by adding custom match conditions
  • You can customize how list of records are queried for a DocType by adding custom match conditions using the permission_query_conditions hook. This match condition must be a valid WHERE clause fragment for an SQL query to restrict users to only see certain records.app/hooks.py

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:

  • This hook is used to define custom logic for checking whether a user has permission to view a specific document.
  • For example, if you want to restrict John from viewing certain records within a Doctype, you can use this hook to check for conditions before allowing access.
  • You can modify the behavior of doc.has_permission document method for any DocType and add custom permission checking logic using the has_permission hook.

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

Use Case for User Permissions and Linked Documents

Article written by Husam Hammad reviewed by Alaa Alsalehi

要查看或添加评论,请登录

RUKN的更多文章

社区洞察

其他会员也浏览了