
Frappe Errors Permissions
- 1 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Diagnoses Frappe permission errors including PermissionError, has_permission failures, over-restrictive User Permissions, perm_level, and sharing issues.
About
A troubleshooting skill for diagnosing Frappe/ERPNext permission errors and access issues. A developer uses it when users are wrongly blocked or granted access, or sharing and perm-level rules misbehave.
- Diagnoses PermissionError, has_permission failures, and over/under-restrictive User Permissions
- Includes permission debug workflow via get_doc_permissions
Frappe Errors Permissions by the numbers
- 1 all-time installs (skills.sh)
- Ranked #489 of 596 Debugging skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-errors-permissionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Diagnoses Frappe permission errors including PermissionError, has_permission failures, over-restrictive User Permissions, perm_level, and sharing issues.
Files
Permission Error Handling
For permission system overview see frappe-core-permissions. For hook syntax see frappe-syntax-hooks.
---
Quick Diagnostic: Error Message -> Cause -> Fix
| 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 |
---
Decision Tree: Where Is the Error?
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 Errors
has_permission Hook: NEVER Throw
# hooks.py
has_permission = {
"Sales Order": "myapp.permissions.sales_order_has_permission",
}# WRONG — Breaks ALL document access
def sales_order_has_permission(doc, user, permission_type):
if doc.status == "Locked":
frappe.throw("Locked") # NEVER do this
# CORRECT — Return False to deny, None to defer
def sales_order_has_permission(doc, user, permission_type):
"""
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
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
return None # Defer to standard permission system
except Exception:
frappe.log_error(frappe.get_traceback(),
f"has_permission error: {getattr(doc, 'name', 'unknown')}")
return None # Safe fallback — deferCritical rules for has_permission hooks:
- ALWAYS return
Noneto defer,Falseto deny. NEVER returnTrue— 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
AdministratorandSystem Managerat the top. - NEVER call
frappe.throw()inside this hook.
permission_query_conditions: NEVER Throw
# hooks.py
permission_query_conditions = {
"Sales Order": "myapp.permissions.sales_order_query",
}# WRONG — Breaks list view for all users
def sales_order_query(user):
if not user:
frappe.throw("User required") # NEVER do this
return f"owner = '{user}'" # SQL injection!
# CORRECT — Return SQL string or empty string
def sales_order_query(user):
"""
ALWAYS return a string. Empty string = no restriction.
ALWAYS use frappe.db.escape(). ALWAYS wrap in try/except.
"""
try:
user = user or frappe.session.user
if user == "Administrator":
return ""
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(), "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 affectfrappe.get_all()/frappe.db.get_all().
---
User Permission Errors
Too Restrictive: Records Disappear
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:
# Step 1: Check what User Permissions exist
frappe.get_all("User Permission",
filters={"user": "john@example.com"},
fields=["allow", "for_value", "applicable_for"])
# 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]
# 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_forfield [v14+] to limit which DocType a User Permission applies to. - Uncheck "Apply User Permissions" on the role permission row if blanket filtering is unwanted.
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.
---
perm_level Errors
Error: Field "cost_center" is invisible despite user having read permission.
Cause: Field has permlevel=1 but role only has permission for permlevel=0.# Check which perm_levels a role has access to
frappe.get_all("DocPerm",
filters={"parent": "Sales Invoice", "role": "Accounts User"},
fields=["permlevel", "read", "write"])Fix: Add a new row in the DocType's Permission table for the role at the required permlevel.
---
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.# 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.addacceptsnotify=1to send email notification.
---
Guest Access Errors
Error: "Not permitted" for unauthenticated users.
Cause: DocType has no Guest read permission, or API missing allow_guest.Fix for web pages / portal:
# Add Guest read permission in DocType Permission table
# Role: Guest, Level: 0, Read: checkedFix for API endpoints:
@frappe.whitelist(allow_guest=True)
def public_endpoint():
# ALWAYS validate input — guest endpoints are exposed to the internet
passNEVER grant Guest write/create/delete permissions unless the DocType is specifically designed for public submission (e.g., Web Form backend).
---
Debug Workflow: frappe.permissions
import frappe
from frappe.permissions import get_doc_permissions
# 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, ...}
# Check specific permission with full context
frappe.has_permission("Sales Order", ptype="write",
doc="SO-001", user="john@example.com", throw=False)
# 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"])---
Critical Rules
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 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 architecturefrappe-errors-api— API error handling (401/403/404)frappe-errors-hooks— Hook error handling patternsfrappe-syntax-hooks— Hook registration syntax
Anti-Patterns — Permission Error Handling
Common mistakes to avoid. Each entry follows: WRONG -> CORRECT -> WHY.
---
1. Throwing in has_permission Hook
# WRONG — Breaks ALL document access
def has_permission(doc, ptype, user):
if doc.status == "Locked":
frappe.throw("Document is locked")
# CORRECT — Return False to deny silently
def has_permission(doc, ptype, user):
try:
if doc.get("status") == "Locked" and ptype != "read":
return False
return None
except Exception:
frappe.log_error(frappe.get_traceback(), "Permission Error")
return NoneWhy: frappe.throw() inside has_permission raises an exception that breaks ALL access to the DocType, including read.
---
2. Returning True in has_permission
# WRONG — True has no effect
def has_permission(doc, ptype, user):
if user == doc.owner:
return True # Does nothing!
# CORRECT — Return None to defer to standard system
def has_permission(doc, ptype, user):
if user == doc.get("owner"):
return None # Let standard system handle
if not meets_criteria(doc, user):
return False
return NoneWhy: has_permission hooks can only deny (return False). Returning True is ignored — hooks NEVER grant access.
---
3. SQL Injection in permission_query_conditions
# WRONG — SQL injection vulnerability
def query_conditions(user):
return f"owner = '{user}'"
# CORRECT — ALWAYS escape
def query_conditions(user):
return f"owner = {frappe.db.escape(user)}"Why: Unescaped input allows attackers to bypass all permission controls via SQL injection.
---
4. Throwing in permission_query_conditions
# WRONG — Breaks list view for ALL users
def query_conditions(user):
if not user:
frappe.throw("User required")
# CORRECT — Handle gracefully
def query_conditions(user):
try:
user = user or frappe.session.user
return f"owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), "Query Error")
return f"owner = {frappe.db.escape(frappe.session.user)}"Why: Exceptions in query conditions crash the entire list view for all users of that DocType.
---
5. Using get_all Instead of get_list
# WRONG — Bypasses ALL permission checks
@frappe.whitelist()
def get_user_orders():
return frappe.get_all("Sales Order", filters={"customer": customer})
# CORRECT — Respects user permissions and permission_query_conditions
@frappe.whitelist()
def get_user_orders():
return frappe.get_list("Sales Order", filters={"customer": customer})Why: frappe.get_all() bypasses user permissions AND permission_query_conditions. ALWAYS use get_list() for user-facing queries.
---
6. No try/except in Permission Hooks
# WRONG — Crash breaks all document access
def has_permission(doc, ptype, user):
territories = frappe.get_all("User Permission", filters={"user": user})
if doc.territory not in territories:
return False
# CORRECT — Wrapped with safe fallback
def has_permission(doc, ptype, user):
try:
territories = frappe.get_all("User Permission",
filters={"user": user, "allow": "Territory"}, pluck="for_value") or []
territory = doc.get("territory")
if territory and territories and territory not in territories:
return False
return None
except Exception:
frappe.log_error(frappe.get_traceback(), "Permission Error")
return NoneWhy: Any unhandled exception in a permission hook completely breaks access to that DocType.
---
7. Not Checking System Manager in Custom Hook
# WRONG — System Manager blocked by custom logic
def has_permission(doc, ptype, user):
if doc.get("department") != get_user_department(user):
return False
# CORRECT — ALWAYS bypass for System Manager / Administrator
def has_permission(doc, ptype, user):
try:
user = user or frappe.session.user
if user == "Administrator":
return None
if "System Manager" in frappe.get_roles(user):
return None
if doc.get("department") != get_user_department(user):
return False
return None
except Exception:
return NoneWhy: System Managers expect full access. Blocking them causes confusion and support tickets.
---
8. Missing Permission Check in API Endpoint
# WRONG — Any logged-in user can update salary
@frappe.whitelist()
def update_salary(employee, new_salary):
frappe.db.set_value("Employee", employee, "salary", new_salary)
# CORRECT — Check permission first
@frappe.whitelist()
def update_salary(employee, new_salary):
frappe.has_permission("Employee", "write", employee, throw=True)
frappe.only_for(["HR Manager"])
frappe.db.set_value("Employee", employee, "salary", new_salary)Why: @frappe.whitelist() makes the function callable by ANY logged-in user. Permission checks are your responsibility.
---
9. Blocking Read When Only Write Should Be Blocked
# WRONG — Can't even view locked documents
def has_permission(doc, ptype, user):
if doc.get("status") == "Locked":
return False # Blocks read too!
# CORRECT — Only block modifications
def has_permission(doc, ptype, user):
if doc.get("status") == "Locked":
if ptype in ("write", "delete", "submit", "cancel"):
return False
return NoneWhy: Users ALWAYS need to read documents they cannot modify. Blocking read makes locked documents invisible.
---
10. Not Handling None Values Safely
# WRONG — Crashes if territory is None
def has_permission(doc, ptype, user):
if doc.territory not in allowed_territories:
return False
# CORRECT — Guard against None
def has_permission(doc, ptype, user):
territory = doc.get("territory") if hasattr(doc, "get") else getattr(doc, "territory", None)
if territory and allowed_territories and territory not in allowed_territories:
return False
return NoneWhy: Document fields can be None. Permission hooks receive both Document objects and dicts, requiring safe access.
---
11. Using ignore_permissions Without Documentation
# WRONG — No justification for bypassing permissions
def process():
docs = frappe.get_all("Confidential Doc")
for doc in docs:
d = frappe.get_doc("Confidential Doc", doc.name)
d.flags.ignore_permissions = True
d.save()
# CORRECT — Document WHY and restrict WHO
def process():
"""System cleanup task — runs as Administrator in scheduler only."""
if frappe.session.user != "Administrator":
frappe.throw(_("System function only"))
docs = frappe.get_all("Doc", filters={"status": "Pending"})
for doc in docs:
d = frappe.get_doc("Doc", doc.name)
d.flags.ignore_permissions = True # Scheduler task — no user context
d.save()Why: Undocumented ignore_permissions creates security audit gaps and makes code reviews harder.
---
12. Checking Permissions After Action
# WRONG — Deletes first, checks later
@frappe.whitelist()
def delete_document(name):
frappe.delete_doc("Important Doc", name)
if not frappe.has_permission("Important Doc", "delete"):
frappe.throw("No permission") # Too late!
# CORRECT — ALWAYS check before action
@frappe.whitelist()
def delete_document(name):
frappe.has_permission("Important Doc", "delete", name, throw=True)
frappe.delete_doc("Important Doc", name)Why: The action is already performed. Permission checks MUST happen before any state change.
---
13. Exposing Sensitive Info in Permission Errors
# WRONG — Leaks internal user list
def has_permission(doc, ptype, user):
allowed = ["admin@company.com", "ceo@company.com"]
if user not in allowed:
frappe.throw(f"Only {allowed} can access") # Exposes emails!
# CORRECT — Silent deny, no information leak
def has_permission(doc, ptype, user):
if user not in get_allowed_users():
return False
return NoneWhy: Error messages can leak internal system configuration to unauthorized users.
---
14. Assuming Sharing Replaces Role Permissions
# WRONG — Sharing alone does not grant access
frappe.share.add("Sales Order", "SO-001", "john@example.com", read=1, write=1)
# John still gets PermissionError if he has no role with Sales Order access
# CORRECT — User MUST have base role permission first
# 1. Ensure user has a role with at least read on Sales Order
# 2. Then share for document-level access
frappe.share.add("Sales Order", "SO-001", "john@example.com", read=1, write=1)Why: Sharing supplements role permissions — it never replaces them. The user MUST have at least one role with read access to the DocType.
---
15. Not Logging Permission Denials
# WRONG — No audit trail
def has_permission(doc, ptype, user):
if doc.get("is_confidential") and user not in allowed:
return False # Silent, no record
# CORRECT — Log for security audit
def has_permission(doc, ptype, user):
if doc.get("is_confidential") and user not in get_allowed(doc):
try:
frappe.log_error(
f"Access denied: {user} attempted {ptype} on {getattr(doc, 'name', 'unknown')}",
"Permission Audit")
except Exception:
pass # NEVER break permission check for logging
return False
return NoneWhy: Security compliance requires logging denied access attempts, especially for sensitive data.
---
Checklist Before Deploying Permission Code
- [ ] No
frappe.throw()in has_permission hooks - [ ] No
frappe.throw()in permission_query_conditions - [ ] NEVER return True in has_permission (only False or None)
- [ ] All SQL uses
frappe.db.escape() - [ ] All hooks wrapped in try/except with safe fallback
- [ ]
frappe.get_list()used for user queries (notget_all) - [ ] System Manager / Administrator checked first in custom hooks
- [ ] Permission checks BEFORE actions (not after)
- [ ]
ignore_permissionsusage documented with justification - [ ] Access denials logged for audit
- [ ] None values handled safely (doc may be dict or object)
- [ ] Read permission preserved when blocking write
Examples — Permission Error Handling
Complete working examples for Frappe permission error handling.
---
Example 1: Complete hooks.py Permission Setup
# myapp/hooks.py
app_name = "myapp"
app_title = "My App"
has_permission = {
"Sales Order": "myapp.permissions.sales_order_has_permission",
"Confidential Report": "myapp.permissions.confidential_has_permission",
}
permission_query_conditions = {
"Sales Order": "myapp.permissions.sales_order_query",
"Confidential Report": "myapp.permissions.confidential_query",
}# myapp/permissions.py
import frappe
from frappe import _
# ── Sales Order Permissions ──────────────────────────────────────────
def sales_order_has_permission(doc, user, permission_type):
"""
Rules:
- Cancelled orders: read-only except System Manager
- Orders > 100k: need Sales Manager for submit
- Territory restrictions apply via User Permissions
"""
try:
user = user or frappe.session.user
if user == "Administrator":
return None
roles = frappe.get_roles(user)
if "System Manager" in roles:
return None
docstatus = doc.get("docstatus") if hasattr(doc, "get") else getattr(doc, "docstatus", 0)
grand_total = doc.get("grand_total") if hasattr(doc, "get") else getattr(doc, "grand_total", 0)
# Cancelled = read-only
if docstatus == 2 and permission_type != "read":
return False
# Large orders need manager approval
if permission_type == "submit" and (grand_total or 0) > 100000:
if "Sales Manager" not in roles:
return False
# Territory check
territory = doc.get("territory") if hasattr(doc, "get") else getattr(doc, "territory", None)
if territory and permission_type in ("read", "write"):
if not _has_territory_access(user, territory):
return False
return None
except Exception:
frappe.log_error(frappe.get_traceback(),
f"SO permission error: {getattr(doc, 'name', 'unknown')}")
return None
def sales_order_query(user):
"""Sales Order list filter."""
try:
user = user or frappe.session.user
if user == "Administrator":
return ""
roles = frappe.get_roles(user)
if "System Manager" in roles or "Sales Manager" in roles:
return ""
territories = _get_user_territories(user)
if territories:
escaped = ", ".join([frappe.db.escape(t) for t in territories])
return f"""
(`tabSales Order`.territory IN ({escaped})
OR `tabSales Order`.owner = {frappe.db.escape(user)})
"""
return f"`tabSales Order`.owner = {frappe.db.escape(user)}"
except Exception:
frappe.log_error(frappe.get_traceback(), "SO query error")
return f"`tabSales Order`.owner = {frappe.db.escape(frappe.session.user)}"
# ── Confidential Report Permissions ──────────────────────────────────
def confidential_has_permission(doc, user, permission_type):
"""Strict access — only owner + explicit access list."""
try:
user = user or frappe.session.user
if user == "Administrator":
return None
doc_name = doc.get("name") if hasattr(doc, "get") else getattr(doc, "name", None)
if not doc_name:
return None
owner = doc.get("owner") if hasattr(doc, "get") else getattr(doc, "owner", None)
if user == owner:
return None
has_access = frappe.db.exists("Confidential Report Access",
{"parent": doc_name, "user": user})
if not has_access:
_log_denied_access(doc_name, user, permission_type)
return False
# Check access level
access = frappe.db.get_value("Confidential Report Access",
{"parent": doc_name, "user": user},
["read_access", "write_access"], as_dict=True)
if permission_type == "read" and not access.get("read_access"):
return False
if permission_type in ("write", "delete") and not access.get("write_access"):
return False
return None
except Exception:
frappe.log_error(frappe.get_traceback(),
f"Confidential permission error: {getattr(doc, 'name', 'unknown')}")
return False # DENY on error for confidential docs
def confidential_query(user):
"""Show only accessible confidential reports."""
try:
user = user or frappe.session.user
if user == "Administrator":
return ""
return f"""
(`tabConfidential Report`.owner = {frappe.db.escape(user)}
OR EXISTS (
SELECT 1 FROM `tabConfidential Report Access`
WHERE `tabConfidential Report Access`.parent = `tabConfidential Report`.name
AND `tabConfidential Report Access`.user = {frappe.db.escape(user)}
AND `tabConfidential Report Access`.read_access = 1
))
"""
except Exception:
frappe.log_error(frappe.get_traceback(), "Confidential query error")
return f"`tabConfidential Report`.owner = {frappe.db.escape(frappe.session.user)}"
# ── Helpers ──────────────────────────────────────────────────────────
def _has_territory_access(user, territory):
"""Check if user has territory access. Returns True if no restrictions."""
try:
has_match = frappe.db.exists("User Permission",
{"user": user, "allow": "Territory", "for_value": territory})
has_any = frappe.db.count("User Permission",
{"user": user, "allow": "Territory"})
return has_match or not has_any # No restrictions = access all
except Exception:
return True
def _get_user_territories(user):
"""Get user's permitted territories."""
try:
return frappe.get_all("User Permission",
filters={"user": user, "allow": "Territory"},
pluck="for_value") or []
except Exception:
return []
def _log_denied_access(doc_name, user, ptype):
"""Log denied access. NEVER let this break the permission check."""
try:
frappe.log_error(
f"Access denied: {user} attempted {ptype} on {doc_name}",
"Permission Audit")
except Exception:
pass---
Example 2: Permission Debug Script
Run this in the Frappe console to diagnose permission issues:
# bench --site mysite.local console
import frappe
from frappe.permissions import get_doc_permissions
user = "john@example.com"
doctype = "Sales Order"
docname = "SO-00001"
# 1. Check user roles
print("=== Roles ===")
print(frappe.get_roles(user))
# 2. Check User Permissions
print("\n=== User Permissions ===")
for up in frappe.get_all("User Permission",
filters={"user": user},
fields=["allow", "for_value", "applicable_for", "is_default"]):
print(f" {up.allow} = {up.for_value} (for: {up.applicable_for or 'all'})")
# 3. Check DocType permissions for user's roles
print(f"\n=== DocPerm rows for {doctype} ===")
for dp in frappe.get_all("DocPerm",
filters={"parent": doctype},
fields=["role", "permlevel", "read", "write", "create", "delete",
"submit", "cancel", "if_owner"],
order_by="permlevel asc, role asc"):
if dp.role in frappe.get_roles(user):
print(f" L{dp.permlevel} {dp.role}: R={dp.read} W={dp.write} "
f"C={dp.create} D={dp.delete} if_owner={dp.if_owner}")
# 4. Check effective permissions on specific document
print(f"\n=== Effective permissions on {docname} ===")
doc = frappe.get_doc(doctype, docname)
perms = get_doc_permissions(doc, user=user)
for k, v in perms.items():
if v:
print(f" {k}: {v}")
# 5. Check sharing
print(f"\n=== Sharing on {docname} ===")
for share in frappe.get_all("DocShare",
filters={"share_doctype": doctype, "share_name": docname, "user": user},
fields=["read", "write", "share", "everyone"]):
print(f" read={share.read} write={share.write} share={share.share}")---
Example 3: Client-Side Permission Handling
// myapp/public/js/sales_order.js
frappe.ui.form.on("Sales Order", {
refresh: function(frm) {
// Show buttons based on permissions
if (frm.doc.docstatus === 0 && frappe.perm.has_perm("Sales Order", 0, "write")) {
frm.add_custom_button(__("Special Action"), function() {
perform_special_action(frm);
});
}
// Approve button — managers only
if (frm.doc.status === "Pending Approval" && frappe.user.has_role("Sales Manager")) {
frm.add_custom_button(__("Approve"), function() {
frappe.call({
method: "myapp.api.approve_order",
args: {order_name: frm.doc.name},
freeze: true,
callback: function(r) {
if (r.message && r.message.status === "success") {
frappe.show_alert({message: __("Approved"), indicator: "green"});
frm.reload_doc();
}
},
error: function(r) {
if (r.exc_type === "PermissionError") {
frappe.msgprint({
title: __("Permission Denied"),
message: __("You lack permission to approve this order."),
indicator: "red"
});
}
}
});
}, __("Actions"));
}
// Toggle sensitive fields by role
const is_manager = ["Sales Manager", "System Manager"].some(
role => frappe.user.has_role(role));
frm.toggle_display("margin", is_manager);
frm.toggle_display("cost_center", is_manager);
}
});---
Example 4: Controller with Permission Checks
# myapp/doctype/confidential_document/confidential_document.py
import frappe
from frappe import _
from frappe.model.document import Document
class ConfidentialDocument(Document):
def validate(self):
if self.is_confidential and not self.is_new():
old = self.get_doc_before_save()
if old and not old.is_confidential:
if not self._can_set_confidential():
frappe.throw(_("You lack permission to mark as confidential"),
exc=frappe.PermissionError)
def _can_set_confidential(self):
return any(r in frappe.get_roles()
for r in ["System Manager", "Compliance Manager"])
@frappe.whitelist()
def grant_access(self, user):
"""Grant access — only owner or admin."""
if self.owner != frappe.session.user:
if "System Manager" not in frappe.get_roles():
frappe.throw(_("Only owner can grant access"), exc=frappe.PermissionError)
if not frappe.db.exists("User", user):
frappe.throw(_("User {0} not found").format(user))
if not frappe.db.exists("Confidential Document Access",
{"parent": self.name, "user": user}):
self.append("access_list", {"user": user})
self.save()
return {"status": "success"}Permission Error Handling Patterns
Complete error handling patterns for Frappe permission system. For quick reference see SKILL.md.
---
Pattern 1: has_permission Hook with Full Error Safety
# myapp/permissions.py
import frappe
def sales_order_has_permission(doc, user, permission_type):
"""
Document-level permission hook.
Args:
doc: Document object or dict (may lack methods like .get())
user: User email or None (defaults to current user)
permission_type: "read", "write", "create", "delete", "submit", "cancel"
Returns:
None: Defer to standard permission system (ALWAYS default)
False: Deny permission
NEVER return True — hooks can only restrict, not grant.
NEVER throw from this function. ALWAYS wrap in try/except.
"""
try:
user = user or frappe.session.user
# ALWAYS let Administrator through first
if user == "Administrator":
return None
roles = frappe.get_roles(user)
# ALWAYS let System Manager through
if "System Manager" in roles:
return None
# Safe attribute access — doc may be dict or object
status = doc.get("status") if hasattr(doc, "get") else getattr(doc, "status", None)
docstatus = doc.get("docstatus") if hasattr(doc, "get") else getattr(doc, "docstatus", 0)
is_confidential = doc.get("is_confidential") if hasattr(doc, "get") else getattr(doc, "is_confidential", 0)
# Rule 1: Cancelled documents are read-only
if docstatus == 2 and permission_type != "read":
return False
# Rule 2: Locked documents block modifications
if status == "Locked" and permission_type in ("write", "delete", "cancel"):
if "Sales Manager" not in roles:
return False
# Rule 3: Only managers can delete
if permission_type == "delete" and "Sales Manager" not in roles:
return False
# Rule 4: Confidential access list
if is_confidential:
allowed = _get_confidential_users(doc)
if user not in allowed:
return False
# Rule 5: Territory restriction
if permission_type == "read":
if _check_territory_access(doc, user) is False:
return False
# ALWAYS return None as default — defer to standard system
return None
except Exception:
frappe.log_error(
frappe.get_traceback(),
f"has_permission error: {getattr(doc, 'name', 'unknown')}"
)
return None # SAFE FALLBACK — defer to standard system
def _get_confidential_users(doc):
"""Get users with confidential access. Returns empty list on error."""
try:
doc_name = doc.get("name") if hasattr(doc, "get") else getattr(doc, "name", None)
if not doc_name:
return []
owner = doc.get("owner") if hasattr(doc, "get") else getattr(doc, "owner", None)
allowed = [owner] if owner else []
allowed.extend(
frappe.get_all("Sales Order Access",
filters={"parent": doc_name}, pluck="user") or []
)
return allowed
except Exception:
return []
def _check_territory_access(doc, user):
"""Check territory access. Returns None to defer, False to deny."""
try:
territory = doc.get("territory") if hasattr(doc, "get") else getattr(doc, "territory", None)
if not territory:
return None
user_territories = frappe.get_all("User Permission",
filters={"user": user, "allow": "Territory",
"applicable_for": ["in", ["", "Sales Order"]]},
pluck="for_value")
if not user_territories:
return None # No territory restrictions for this user
if territory not in user_territories:
return False
return None
except Exception:
return None---
Pattern 2: permission_query_conditions with Team and Territory
# myapp/permissions.py
import frappe
def sales_order_query(user):
"""
Return SQL WHERE clause fragment for list filtering.
ALWAYS return a string. Empty string = no restriction.
ALWAYS use frappe.db.escape(). NEVER throw.
"""
try:
user = user or frappe.session.user
if user == "Guest":
return "1=0" # Guest sees nothing
if user == "Administrator":
return ""
roles = frappe.get_roles(user)
if "System Manager" in roles:
return ""
conditions = []
# Sales Manager: all non-confidential + own confidential
if "Sales Manager" in roles:
conditions.append(f"""
(`tabSales Order`.is_confidential = 0
OR `tabSales Order`.owner = {frappe.db.escape(user)}
OR EXISTS (
SELECT 1 FROM `tabSales Order Access`
WHERE `tabSales Order Access`.parent = `tabSales Order`.name
AND `tabSales Order Access`.user = {frappe.db.escape(user)}
))
""")
# Sales User: own records + team records
elif "Sales User" in roles:
team_sql = _get_team_condition(user)
if team_sql:
conditions.append(f"""
(`tabSales Order`.owner = {frappe.db.escape(user)} OR {team_sql})
AND `tabSales Order`.is_confidential = 0
""")
else:
conditions.append(f"""
`tabSales Order`.owner = {frappe.db.escape(user)}
AND `tabSales Order`.is_confidential = 0
""")
# Default: own non-confidential records
else:
conditions.append(f"""
`tabSales Order`.owner = {frappe.db.escape(user)}
AND `tabSales Order`.is_confidential = 0
""")
# Territory filter
territory_sql = _get_territory_condition(user)
if territory_sql:
conditions.append(territory_sql)
return " AND ".join([f"({c.strip()})" for c in conditions])
except Exception:
frappe.log_error(frappe.get_traceback(), f"Query conditions error for {user}")
return f"`tabSales Order`.owner = {frappe.db.escape(frappe.session.user)}"
def _get_team_condition(user):
"""Build SQL for team members. Returns None on error."""
try:
department = frappe.db.get_value("User", user, "department")
if not department:
return None
team = frappe.get_all("User",
filters={"department": department, "enabled": 1}, pluck="name")
if not team or len(team) <= 1:
return None
escaped = ", ".join([frappe.db.escape(u) for u in team])
return f"`tabSales Order`.owner IN ({escaped})"
except Exception:
return None
def _get_territory_condition(user):
"""Build SQL for territory filter. Returns None on error."""
try:
territories = frappe.get_all("User Permission",
filters={"user": user, "allow": "Territory",
"applicable_for": ["in", ["", "Sales Order"]]},
pluck="for_value")
if not territories:
return None
escaped = ", ".join([frappe.db.escape(t) for t in territories])
return f"(`tabSales Order`.territory IN ({escaped}) OR `tabSales Order`.territory IS NULL)"
except Exception:
return None---
Pattern 3: API Endpoint Permission Checks
# myapp/api.py
import frappe
from frappe import _
@frappe.whitelist()
def get_order_details(order_name):
"""API endpoint with layered permission checks."""
if not order_name:
frappe.throw(_("Order name required"), exc=frappe.ValidationError)
if not frappe.db.exists("Sales Order", order_name):
frappe.throw(_("Sales Order {0} not found").format(order_name),
exc=frappe.DoesNotExistError)
# Throws PermissionError automatically if denied
frappe.has_permission("Sales Order", "read", order_name, throw=True)
doc = frappe.get_doc("Sales Order", order_name)
result = {"name": doc.name, "customer": doc.customer, "status": doc.status}
# Filter sensitive fields by role
if "Sales Manager" in frappe.get_roles():
result["margin"] = doc.get("margin")
result["cost"] = doc.get("cost")
return result
@frappe.whitelist()
def approve_order(order_name):
"""Role-restricted endpoint with audit logging."""
frappe.only_for(["Sales Manager", "General Manager"])
doc = frappe.get_doc("Sales Order", order_name)
if not doc.has_permission("write"):
frappe.throw(_("Cannot approve — no write permission"),
exc=frappe.PermissionError)
doc.status = "Approved"
doc.approved_by = frappe.session.user
doc.save()
return {"status": "success"}
@frappe.whitelist()
def bulk_update(orders, status):
"""Bulk operation with per-document permission check."""
orders = frappe.parse_json(orders) if isinstance(orders, str) else orders
results = {"success": [], "failed": [], "permission_denied": []}
for name in orders:
if not frappe.db.exists("Sales Order", name):
results["failed"].append({"name": name, "error": "Not found"})
continue
if not frappe.has_permission("Sales Order", "write", name):
results["permission_denied"].append(name)
continue
try:
frappe.db.set_value("Sales Order", name, "status", status)
results["success"].append(name)
except Exception as e:
results["failed"].append({"name": name, "error": str(e)})
frappe.db.commit()
return results---
Pattern 4: Graceful Permission Degradation
@frappe.whitelist()
def get_dashboard_data():
"""Return data based on user permissions — no errors, just filtered results."""
data = {"widgets": [], "stats": {}}
if frappe.has_permission("Sales Order", "read"):
try:
data["widgets"].append({"type": "sales", "data": get_sales_summary()})
except Exception:
frappe.log_error(frappe.get_traceback(), "Dashboard: Sales Widget")
if frappe.has_permission("Purchase Order", "read"):
try:
data["widgets"].append({"type": "purchase", "data": get_purchase_summary()})
except Exception:
frappe.log_error(frappe.get_traceback(), "Dashboard: Purchase Widget")
return data---
Pattern 5: Security Audit Logging
def log_access_denied(doc, user, ptype, reason=""):
"""Log denied access for security audit. NEVER let this break the permission check."""
try:
frappe.get_doc({
"doctype": "Activity Log",
"subject": f"Access Denied: {ptype} on {getattr(doc, 'name', 'unknown')}",
"content": f"User: {user}, Reason: {reason}",
"reference_doctype": getattr(doc, "doctype", None),
"reference_name": getattr(doc, "name", None),
}).insert(ignore_permissions=True)
except Exception:
pass # NEVER break permission check for logging failure---
Quick Reference
| Scenario | Method | Returns on Deny |
|---|---|---|
| has_permission hook | return False | Silent deny |
| permission_query_conditions | Return restrictive SQL | Filtered list |
| API endpoint | frappe.has_permission(throw=True) | HTTP 403 |
| Role restriction | frappe.only_for(["Role"]) | HTTP 403 |
| Document method | doc.check_permission("write") | HTTP 403 |
| Custom throw | frappe.throw(msg, exc=PermissionError) | HTTP 403 |