v2.0 to v2.0

239 added, 329 removed. Audit A to A.

---
name: frappe-errors-permissions
description: >
- Use when handling permission errors in ERPNext/Frappe. Covers
- PermissionError, has_permission failures, role issues, and document
- access problems for v14/v15/v16. Keywords: permission error, access
- denied, PermissionError, role error, has_permission failed, document
- access error.
+ Use when debugging or handling permission errors in Frappe/ERPNext.
+ Prevents broken document access from throwing in permission hooks.
+ Covers PermissionError (403), has_permission hook failures, User Permission
+ restricting too much or too little, perm_level blocking field access,
+ System Manager bypass not working, Guest access denied, sharing permissions
+ not applying, permission_query_conditions breaking get_list, owner-based
+ permissions confusion, Apply User Permission checkbox behavior, and the
+ permission debug workflow using frappe.permissions.get_doc_permissions.
+ Keywords: PermissionError, has_permission, permission_query_conditions,
+ User Permission, perm_level, sharing, guest access, owner permission.
license: MIT
compatibility: "Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16."
metadata:
author: OpenAEC-Foundation
version: "2.0"
---
- # ERPNext Permissions - Error Handling
-
- This skill covers error handling patterns for the Frappe permission system. For permission syntax, see `frappe-core-permissions`. For hooks, see `frappe-syntax-hooks`.
+ # Permission Error Handling
- **Version**: v14/v15/v16 compatible
+ For permission system overview see `frappe-core-permissions`. For hook syntax see `frappe-syntax-hooks`.
---
- ## Permission Error Handling Overview
+ ## Quick Diagnostic: Error Message -> Cause -> Fix
- ```
- ┌─────────────────────────────────────────────────────────────────────┐
- │ PERMISSION ERRORS REQUIRE SPECIAL HANDLING │
- ├─────────────────────────────────────────────────────────────────────┤
- │ │
- │ Permission Hooks (has_permission, permission_query_conditions): │
- │ ⚠️ NEVER throw errors - return False or empty string │
- │ ⚠️ Errors break document access and list views │
- │ ⚠️ Always provide safe fallbacks │
- │ │
- │ Permission Checks in Code: │
- │ ✅ Use frappe.has_permission() before operations │
- │ ✅ Use throw=True for automatic error handling │
- │ ✅ Catch frappe.PermissionError for custom handling │
- │ │
- │ API Endpoints: │
- │ ✅ frappe.only_for() for role-restricted endpoints │
- │ ✅ doc.has_permission() before document operations │
- │ ✅ Return proper HTTP 403 for access denied │
- │ │
- └─────────────────────────────────────────────────────────────────────┘
- ```
+ | Error Message | Cause | Fix |
+ |---------------|-------|-----|
+ | `frappe.exceptions.PermissionError` | User lacks role or doc-level access | Add role in Role Permissions Manager or grant User Permission |
+ | "Not permitted" on document open | `has_permission` hook returns False or role missing read | Check `frappe.permissions.get_doc_permissions(doc, user)` output |
+ | List view shows 0 records | `permission_query_conditions` returns overly restrictive SQL | Debug the SQL condition; check User Permissions for the Link field |
+ | "Not allowed to access ... for Guest" | Endpoint missing `allow_guest=True` or DocType lacks Guest read | Add `allow_guest=True` to `@frappe.whitelist()` |
+ | Field invisible despite role having read | `perm_level` > 0 on field and role lacks that level | Add role permission row for the specific `perm_level` |
+ | "User Permission restriction" blocking | User Permission on a Link field auto-filters documents | Uncheck "Apply User Permissions" on that role row or add matching User Permission |
+ | Sharing not granting access | Sharing adds access but never overrides role absence | User MUST have base role permission; sharing only adds doc-level grants |
+ | `ignore_permissions` has no effect | Flag set after `get_doc` already checked permissions | Set `flags.ignore_permissions = True` BEFORE calling `save()` or `insert()` |
+ | System Manager cannot access | Custom `has_permission` hook denies without checking role | ALWAYS check for System Manager / Administrator in hook |
---
- ## Main Decision: Where Is the Permission Check?
+ ## Decision Tree: Where Is the Error?
```
- ┌─────────────────────────────────────────────────────────────────────────┐
- │ WHERE ARE YOU HANDLING PERMISSIONS? │
- ├─────────────────────────────────────────────────────────────────────────┤
- │ │
- │ ► has_permission hook (hooks.py) │
- │ └─► NEVER throw - return False to deny, None to defer │
- │ └─► Wrap in try/except, log errors, return None on failure │
- │ │
- │ ► permission_query_conditions hook (hooks.py) │
- │ └─► NEVER throw - return SQL condition or empty string │
- │ └─► Wrap in try/except, return restrictive fallback on failure │
- │ │
- │ ► Whitelisted method / API endpoint │
- │ └─► Use frappe.has_permission() with throw=True │
- │ └─► Or catch PermissionError for custom response │
- │ └─► Use frappe.only_for() for role-restricted endpoints │
- │ │
- │ ► Controller method │
- │ └─► Use doc.has_permission() or doc.check_permission() │
- │ └─► Let PermissionError propagate for standard handling │
- │ │
- │ ► Client Script │
- │ └─► Handle in frappe.call error callback │
- │ └─► Check exc type for PermissionError │
- │ │
- └─────────────────────────────────────────────────────────────────────────┘
+ Permission error occurred
+ ├── Document-level (single doc access)?
+ │ ├── has_permission hook returning False?
+ │ │ └── Debug: frappe.permissions.get_doc_permissions(doc, user)
+ │ ├── User Permission restricting Link field?
+ │ │ └── Check: frappe.get_all("User Permission", filters={"user": user})
+ │ ├── perm_level blocking field?
+ │ │ └── Check: role has permission row for that perm_level
+ │ └── Sharing not applying?
+ │ └── Check: user has base role + sharing record exists
+ ├── List-level (0 records in list view)?
+ │ ├── permission_query_conditions returning bad SQL?
+ │ │ └── Debug: run condition manually in MariaDB console
+ │ ├── User Permission auto-filtering?
+ │ │ └── Check "Apply User Permissions" checkbox on role row
+ │ └── get_all vs get_list confusion?
+ │ └── ALWAYS use get_list for user-facing queries
+ ├── API endpoint (403 response)?
+ │ ├── Missing @frappe.whitelist()?
+ │ │ └── Add decorator to Python method
+ │ ├── Missing allow_guest=True?
+ │ │ └── Add allow_guest parameter for public endpoints
+ │ └── frappe.only_for() blocking?
+ │ └── Check user has required role
+ └── System Manager bypass failing?
+ └── Custom hook does not check for System Manager role
```
---
- ## Permission Hook Error Handling
+ ## Permission Hook Errors
- ### has_permission - NEVER Throw!
+ ### has_permission Hook — NEVER Throw
```python
- # ❌ WRONG - Breaks document access!
- def has_permission(doc, ptype, user):
+ # hooks.py
+ has_permission = {
+ "Sales Order": "myapp.permissions.sales_order_has_permission",
+ }
+ ```
+
+ ```python
+ # WRONG — Breaks ALL document access
+ def sales_order_has_permission(doc, user, permission_type):
if doc.status == "Locked":
- frappe.throw("Document is locked") # DON'T DO THIS!
+ frappe.throw("Locked") # NEVER do this
- # ✅ CORRECT - Return False to deny, None to defer
- def has_permission(doc, ptype, user):
+ # CORRECT — Return False to deny, None to defer
+ def sales_order_has_permission(doc, user, permission_type):
"""
- Custom permission check.
-
- Returns:
- None: Defer to standard permission system
- False: Deny permission
-
- NEVER return True - hooks can only deny, not grant.
+ ALWAYS wrap in try/except. NEVER throw. NEVER return True.
+ Returns: False (deny) or None (defer to standard system).
"""
try:
user = user or frappe.session.user
-
- # Deny editing locked documents
- if ptype == "write" and doc.get("status") == "Locked":
- if "System Manager" not in frappe.get_roles(user):
- return False
-
- # Deny access to confidential documents
- if doc.get("is_confidential"):
- allowed_users = get_allowed_users(doc.name)
- if user not in allowed_users:
+ if user == "Administrator":
+ return None
+
+ # ALWAYS check System Manager early
+ if "System Manager" in frappe.get_roles(user):
+ return None
+
+ # Deny write on locked docs (but allow read)
+ if permission_type in ("write", "delete", "cancel"):
+ if doc.get("status") == "Locked":
return False
-
- # ALWAYS return None to defer to standard checks
- return None
-
+
+ return None # Defer to standard permission system
+
except Exception:
- frappe.log_error(
- frappe.get_traceback(),
- f"Permission check error: {doc.name if hasattr(doc, 'name') else 'unknown'}"
- )
- # Safe fallback - defer to standard system
- return None
+ frappe.log_error(frappe.get_traceback(),
+ f"has_permission error: {getattr(doc, 'name', 'unknown')}")
+ return None # Safe fallback — defer
```
- ### permission_query_conditions - NEVER Throw!
+ **Critical rules for has_permission hooks:**
+ - ALWAYS return `None` to defer, `False` to deny. NEVER return `True` — hooks can only restrict, not grant.
+ - ALWAYS wrap the entire function in `try/except`. An unhandled exception breaks ALL access to that DocType.
+ - ALWAYS check for `Administrator` and `System Manager` at the top.
+ - NEVER call `frappe.throw()` inside this hook.
+ ### permission_query_conditions — NEVER Throw
+
```python
- # ❌ WRONG - Breaks list views!
- def query_conditions(user):
+ # hooks.py
+ permission_query_conditions = {
+ "Sales Order": "myapp.permissions.sales_order_query",
+ }
+ ```
+
+ ```python
+ # WRONG — Breaks list view for all users
+ def sales_order_query(user):
if not user:
- frappe.throw("User required")
- return f"owner = '{user}'" # Also SQL injection!
+ frappe.throw("User required") # NEVER do this
+ return f"owner = '{user}'" # SQL injection!
- # ✅ CORRECT - Return safe SQL condition
- def query_conditions(user):
+ # CORRECT — Return SQL string or empty string
+ def sales_order_query(user):
"""
- Return SQL WHERE clause fragment for list filtering.
-
- Returns:
- str: SQL condition (empty string for no restriction)
-
- NEVER throw errors - return restrictive fallback.
+ ALWAYS return a string. Empty string = no restriction.
+ ALWAYS use frappe.db.escape(). ALWAYS wrap in try/except.
"""
try:
- if not user:
- user = frappe.session.user
-
- roles = frappe.get_roles(user)
-
- # Admins see all
- if "System Manager" in roles:
+ user = user or frappe.session.user
+ if user == "Administrator":
return ""
-
- # Managers see team records
- if "Sales Manager" in roles:
- team_users = get_team_users(user)
- if team_users:
- escaped = ", ".join([frappe.db.escape(u) for u in team_users])
- return f"`tabSales Order`.owner IN ({escaped})"
-
- # Default: own records only
+ if "System Manager" in frappe.get_roles(user):
+ return ""
+
return f"`tabSales Order`.owner = {frappe.db.escape(user)}"
-
+
except Exception:
- frappe.log_error(
- frappe.get_traceback(),
- f"Permission query error for {user}"
- )
- # SAFE FALLBACK: Most restrictive - own records only
+ frappe.log_error(frappe.get_traceback(), "Query conditions error")
+ # SAFE FALLBACK: most restrictive
return f"`tabSales Order`.owner = {frappe.db.escape(frappe.session.user)}"
```
+ **Critical rules for permission_query_conditions:**
+ - NEVER throw errors — return `"1=0"` to deny all or a restrictive SQL string.
+ - ALWAYS use `frappe.db.escape()` for every user-supplied value.
+ - This hook ONLY affects `frappe.get_list()` / `frappe.db.get_list()`. It does NOT affect `frappe.get_all()` / `frappe.db.get_all()`.
+
---
- ## Permission Check Error Handling
+ ## User Permission Errors
- ### Pattern 1: Check Before Action (Recommended)
+ ### Too Restrictive — Records Disappear
- ```python
- @frappe.whitelist()
- def update_order_status(order_name, new_status):
- """Update order status with permission check."""
- # Check document exists
- if not frappe.db.exists("Sales Order", order_name):
- frappe.throw(
- _("Sales Order {0} not found").format(order_name),
- exc=frappe.DoesNotExistError
- )
-
- # Check permission - throws automatically
- frappe.has_permission("Sales Order", "write", order_name, throw=True)
-
- # Now safe to proceed
- frappe.db.set_value("Sales Order", order_name, "status", new_status)
-
- return {"status": "success"}
```
-
- ### Pattern 2: Custom Permission Error Response
+ Error: User can't see any Sales Orders despite having Sales User role.
+ Cause: A User Permission for "Company" exists, and "Apply User Permissions"
+ is checked on the Sales Order role row. Sales Order has a Company
+ Link field, so ALL Sales Orders are filtered by that Company value.
+ ```
+ **Debug steps:**
```python
- @frappe.whitelist()
- def sensitive_operation(doc_name):
- """Operation with custom permission error handling."""
- try:
- doc = frappe.get_doc("Sensitive Doc", doc_name)
- doc.check_permission("write")
-
- except frappe.DoesNotExistError:
- frappe.throw(
- _("Document not found"),
- exc=frappe.DoesNotExistError
- )
-
- except frappe.PermissionError:
- # Log attempted access
- frappe.log_error(
- f"Unauthorized access attempt: {doc_name} by {frappe.session.user}",
- "Security Alert"
- )
- # Custom error message
- frappe.throw(
- _("You don't have permission to perform this action. This incident has been logged."),
- exc=frappe.PermissionError
- )
-
- # Proceed with operation
- return process_document(doc)
- ```
+ # Step 1: Check what User Permissions exist
+ frappe.get_all("User Permission",
+ filters={"user": "john@example.com"},
+ fields=["allow", "for_value", "applicable_for"])
- ### Pattern 3: Role-Restricted Endpoint
+ # Step 2: Check if Apply User Permissions is checked
+ frappe.get_all("DocPerm",
+ filters={"parent": "Sales Order", "role": "Sales User"},
+ fields=["role", "permlevel", "apply_user_permissions"]) # [v14]
- ```python
- @frappe.whitelist()
- def admin_dashboard_data():
- """Endpoint restricted to specific roles."""
- # This throws PermissionError if user lacks role
- frappe.only_for(["System Manager", "Dashboard Admin"])
-
- # Only reaches here if authorized
- return compile_dashboard_data()
+ # Step 3: Check effective permissions on a specific doc
+ from frappe.permissions import get_doc_permissions
+ perms = get_doc_permissions(frappe.get_doc("Sales Order", "SO-001"), "john@example.com")
+ ```
+ **Fix patterns:**
+ - Remove overly broad User Permissions that filter unintended DocTypes.
+ - Use the `applicable_for` field [v14+] to limit which DocType a User Permission applies to.
+ - Uncheck "Apply User Permissions" on the role permission row if blanket filtering is unwanted.
- @frappe.whitelist()
- def manager_report():
- """Endpoint with graceful role check."""
- allowed_roles = ["Sales Manager", "General Manager", "System Manager"]
- user_roles = frappe.get_roles()
-
- if not any(role in user_roles for role in allowed_roles):
- frappe.throw(
- _("This report is only available to managers"),
- exc=frappe.PermissionError
- )
-
- return generate_report()
+ ### Too Permissive — User Sees Everything
+
```
+ Error: User Permission set for Territory = "North" but user sees all territories.
+ Cause: "Apply User Permissions" is NOT checked on the role permission row,
+ or the DocType has no Link field for Territory.
+ ```
+ **Fix:** Ensure the role permission row has "Apply User Permissions" checked AND the DocType has a Link field to the restricted DocType.
+
---
- ## Error Response Patterns
+ ## perm_level Errors
- ### Standard Permission Error
+ ```
+ Error: Field "cost_center" is invisible despite user having read permission.
+ Cause: Field has permlevel=1 but role only has permission for permlevel=0.
+ ```
```python
- # Uses frappe.PermissionError - returns HTTP 403
- frappe.throw(
- _("You don't have permission to access this resource"),
- exc=frappe.PermissionError
- )
+ # Check which perm_levels a role has access to
+ frappe.get_all("DocPerm",
+ filters={"parent": "Sales Invoice", "role": "Accounts User"},
+ fields=["permlevel", "read", "write"])
```
- ### Permission Error with Context
+ **Fix:** Add a new row in the DocType's Permission table for the role at the required `permlevel`.
- ```python
- def check_access(doc):
- """Check access with helpful error message."""
- if not doc.has_permission("read"):
- owner_name = frappe.db.get_value("User", doc.owner, "full_name")
- frappe.throw(
- _("This document belongs to {0}. You can only view your own documents.").format(owner_name),
- exc=frappe.PermissionError
- )
- ```
+ ---
- ### Soft Permission Denial (No Error)
+ ## Sharing Permission Errors
+ ```
+ Error: Document shared with user but user still gets PermissionError.
+ Cause: User has NO base role permission on the DocType. Sharing only
+ supplements — it never replaces role-based permissions.
+ ```
+
```python
- @frappe.whitelist()
- def get_dashboard_widgets():
- """Return widgets based on user permissions."""
- widgets = []
-
- # Add widgets based on permissions
- if frappe.has_permission("Sales Order", "read"):
- widgets.append(get_sales_widget())
-
- if frappe.has_permission("Purchase Order", "read"):
- widgets.append(get_purchase_widget())
-
- if frappe.has_permission("Employee", "read"):
- widgets.append(get_hr_widget())
-
- # No error if no widgets - just return empty
- return widgets
+ # Share a document (user MUST already have a role with at least read)
+ frappe.share.add("Sales Order", "SO-001", "john@example.com",
+ read=1, write=1, share=1)
+
+ # Check if sharing grants access
+ frappe.share.get_sharing_permissions("Sales Order", "SO-001", "john@example.com")
```
- ---
+ **Rules:**
+ - ALWAYS ensure the user has at least one role with read permission on the DocType before sharing.
+ - Sharing adds document-level grants on top of role permissions.
+ - [v15+] `frappe.share.add` accepts `notify=1` to send email notification.
- ## Client-Side Permission Error Handling
+ ---
- ### JavaScript Error Handling
+ ## Guest Access Errors
- ```javascript
- // Handle permission errors in frappe.call
- frappe.call({
- method: "myapp.api.sensitive_operation",
- args: { doc_name: "DOC-001" },
- callback: function(r) {
- if (r.message) {
- frappe.show_alert({
- message: __("Operation completed"),
- indicator: "green"
- });
- }
- },
- error: function(r) {
- // Check if it's a permission error
- if (r.exc_type === "PermissionError") {
- frappe.msgprint({
- title: __("Access Denied"),
- message: __("You don't have permission to perform this action."),
- indicator: "red"
- });
- } else {
- // Generic error handling
- frappe.msgprint({
- title: __("Error"),
- message: r.exc || __("An error occurred"),
- indicator: "red"
- });
- }
- }
- });
```
-
- ### Permission Check Before Action
+ Error: "Not permitted" for unauthenticated users.
+ Cause: DocType has no Guest read permission, or API missing allow_guest.
+ ```
- ```javascript
- // Check permission before showing button
- frappe.ui.form.on("Sales Order", {
- refresh: function(frm) {
- // Only show button if user has write permission
- if (frm.doc.docstatus === 0 && frappe.perm.has_perm("Sales Order", 0, "write")) {
- frm.add_custom_button(__("Special Action"), function() {
- perform_special_action(frm);
- });
- }
- }
- });
+ **Fix for web pages / portal:**
+ ```python
+ # Add Guest read permission in DocType Permission table
+ # Role: Guest, Level: 0, Read: checked
+ ```
- // Or use frappe.call to check server-side
- frappe.call({
- method: "frappe.client.has_permission",
- args: {
- doctype: "Sales Order",
- docname: frm.doc.name,
- ptype: "write"
- },
- async: false,
- callback: function(r) {
- if (r.message) {
- // Has permission - show button
- }
- }
- });
+ **Fix for API endpoints:**
+ ```python
+ @frappe.whitelist(allow_guest=True)
+ def public_endpoint():
+ # ALWAYS validate input — guest endpoints are exposed to the internet
+ pass
```
+ **NEVER grant Guest write/create/delete permissions** unless the DocType is specifically designed for public submission (e.g., Web Form backend).
+
---
- ## Critical Rules
+ ## Debug Workflow: frappe.permissions
- ### ✅ ALWAYS
+ ```python
+ import frappe
+ from frappe.permissions import get_doc_permissions
- 1. **Return None in has_permission hooks** - Never return True
- 2. **Use frappe.db.escape() in query conditions** - Prevent SQL injection
- 3. **Wrap hooks in try/except** - Errors break access entirely
- 4. **Log permission errors** - Security audit trail
- 5. **Use throw=True in permission checks** - Automatic error handling
- 6. **Provide helpful error messages** - Tell users what they can do
+ # Get all effective permissions for a user on a document
+ doc = frappe.get_doc("Sales Order", "SO-001")
+ perms = get_doc_permissions(doc, user="john@example.com")
+ # Returns dict: {"read": 1, "write": 0, "create": 0, ...}
- ### ❌ NEVER
+ # Check specific permission with full context
+ frappe.has_permission("Sales Order", ptype="write",
+ doc="SO-001", user="john@example.com", throw=False)
- 1. **Don't throw in permission hooks** - Return False instead
- 2. **Don't use string concatenation in SQL** - SQL injection risk
- 3. **Don't return True in has_permission** - Hooks can only deny
- 4. **Don't ignore permission errors** - Security risk
- 5. **Don't expose sensitive info in errors** - Security risk
+ # List all roles for a user
+ frappe.get_roles("john@example.com")
+ # Check User Permissions
+ frappe.get_all("User Permission",
+ filters={"user": "john@example.com"},
+ fields=["allow", "for_value", "applicable_for", "is_default"])
+ ```
+
---
- ## Quick Reference: Permission Error Handling
+ ## Critical Rules
- | Context | Error Method | Fallback |
- |---------|--------------|----------|
- | has_permission hook | Return False | Return None |
- | permission_query_conditions | Return restrictive SQL | Own records filter |
- | Whitelisted method | frappe.throw(exc=PermissionError) | N/A |
- | Controller | doc.check_permission() | Let propagate |
- | Client Script | error callback | Show user message |
+ ### ALWAYS
+ 1. **Wrap permission hooks in try/except** — unhandled errors break all access
+ 2. **Return None (not True) in has_permission** — hooks can only deny
+ 3. **Use frappe.db.escape() in query conditions** — prevent SQL injection
+ 4. **Check System Manager / Administrator first** in custom hooks
+ 5. **Use frappe.has_permission(throw=True)** for endpoint permission checks
+ 6. **Use get_list (not get_all)** for user-facing queries — get_all bypasses permissions
+ 7. **Log permission denials** for security audit with `frappe.log_error()`
+ ### NEVER
+ 1. **Throw in has_permission or permission_query_conditions** — breaks access entirely
+ 2. **Return True in has_permission** — has no effect, hooks can only restrict
+ 3. **Use string formatting for SQL** — use `frappe.db.escape()` to prevent injection
+ 4. **Grant Guest write/delete permissions** — security risk
+ 5. **Use ignore_permissions without documenting why** — creates audit gaps
+ 6. **Assume sharing replaces role permissions** — sharing only supplements
+
---
## Reference Files
| File | Contents |
|------|----------|
- | `references/patterns.md` | Complete error handling patterns |
- | `references/examples.md` | Full working examples |
- | `references/anti-patterns.md` | Common mistakes to avoid |
+ | `references/patterns.md` | Complete hook patterns, query conditions, API endpoints |
+ | `references/examples.md` | Full working examples with hooks.py configuration |
+ | `references/anti-patterns.md` | 15 common mistakes with wrong/correct comparisons |
---
## See Also
- - `frappe-core-permissions` - Permission system overview
- - `frappe-errors-hooks` - Hook error handling
- - `frappe-errors-api` - API error handling
- - `frappe-syntax-hooks` - Hook syntax
+ - `frappe-core-permissions` — Permission system architecture
+ - `frappe-errors-api` — API error handling (401/403/404)
+ - `frappe-errors-hooks` — Hook error handling patterns
+ - `frappe-syntax-hooks` — Hook registration syntax