| name | frappe-core-permissions |
| description | Use when implementing the Frappe/ERPNext permission system. Covers roles, user permissions, perm levels, data masking, and permission hooks for v14/v15/v16. Prevents common access control mistakes and security issues. Keywords: permissions, roles, user permissions, perm levels, data masking,, restrict records, who can see what, department access, row-level, user cannot see document, access denied. access control, security, has_permission.
|
| license | MIT |
| compatibility | Claude Code, Claude.ai Projects, Claude API. Frappe v14-v16. |
| metadata | {"author":"OpenAEC-Foundation","version":"2.0"} |
Frappe Permissions
Deterministic patterns for the five-layer Frappe permission system.
Permission Layers
| Layer | Controls | Configured Via | Version |
|---|
| Role Permissions | What users CAN do | DocType permissions table | All |
| User Permissions | WHICH records users see | User Permission DocType | All |
| Perm Levels | WHICH fields users see/edit | Field permlevel property | All |
| Permission Hooks | Custom deny logic | hooks.py | All |
| Data Masking | Masked field values | Field mask property | [v16+] |
Decision Tree
Need to control access?
├── Who can Create/Read/Write/Delete a DocType? → Role Permissions
├── Which specific records can a user see? → User Permissions
├── Which fields should be hidden? → Perm Levels (permlevel 1+)
├── Which fields show masked values? → Data Masking [v16+]
├── Custom runtime deny logic? → has_permission hook
├── Filter list queries dynamically? → permission_query_conditions hook
└── Share one document with one user? → frappe.share
Checking permissions in code?
├── Before action → frappe.has_permission() or doc.has_permission()
├── Raise on denial → doc.check_permission() or throw=True
├── System bypass → doc.flags.ignore_permissions = True (ALWAYS document why)
└── List query → ALWAYS use frappe.get_list() for user-facing data
Permission Types
| Type | API Check | Applies To |
|---|
read | frappe.has_permission(dt, "read") | All DocTypes |
write | frappe.has_permission(dt, "write") | All DocTypes |
create | frappe.has_permission(dt, "create") | All DocTypes |
delete | frappe.has_permission(dt, "delete") | All DocTypes |
submit | frappe.has_permission(dt, "submit") | Submittable only |
cancel | frappe.has_permission(dt, "cancel") | Submittable only |
amend | frappe.has_permission(dt, "amend") | Submittable only |
select | frappe.has_permission(dt, "select") | Link fields [v14+] |
report | N/A | Report Builder access |
export | N/A | Excel/CSV export |
import | N/A | Data Import Tool |
share | N/A | Share with other users |
print | N/A | Print/PDF generation |
email | N/A | Send email |
mask | Role permission for unmasked view | Data Masking [v16+] |
Automatic Roles
| Role | Assigned To | Notes |
|---|
Guest | Everyone (including anonymous) | Public pages |
All | All registered users | Basic authenticated access |
Administrator | Only the Administrator user | ALWAYS has all permissions |
Desk User | System Users only | [v15+] |
Essential API
Check Permission
frappe.has_permission("Sales Order", "write")
frappe.has_permission("Sales Order", "write", "SO-00001")
frappe.has_permission("Sales Order", "write", doc=doc)
frappe.has_permission("Sales Order", "read", user="john@example.com")
frappe.has_permission("Sales Order", "delete", throw=True)
frappe.has_permission("Sales Order", "read", debug=True)
print(frappe.local.permission_debug_log)
Document Instance Methods
doc = frappe.get_doc("Sales Order", "SO-00001")
if doc.has_permission("write"):
doc.status = "Approved"
doc.save()
doc.check_permission("write")
Get Effective Permissions
from frappe.permissions import get_doc_permissions
perms = get_doc_permissions(doc)
perms = get_doc_permissions(doc, user="john@example.com")
User Permissions (Record-Level)
Restrict users to specific Link field values (e.g., specific Company, Territory).
from frappe.permissions import add_user_permission, remove_user_permission
add_user_permission(
doctype="Company",
name="My Company",
user="john@example.com",
is_default=1,
applicable_for="Sales Order"
)
remove_user_permission("Company", "My Company", "john@example.com")
from frappe.permissions import get_user_permissions
perms = get_user_permissions("john@example.com")
Sharing (Document-Level)
Grant access to a single document for a specific user.
from frappe.share import add as add_share, remove as remove_share
add_share("Sales Order", "SO-00001", "jane@example.com",
read=1, write=1, share=0, notify=1)
remove_share("Sales Order", "SO-00001", "jane@example.com")
add_share("Sales Order", "SO-00001", everyone=1, read=1)
Field-Level Permissions (Perm Levels)
Group fields by permlevel (0-9). Level 0 MUST be granted before higher levels.
{
"fields": [
{"fieldname": "employee_name", "permlevel": 0},
{"fieldname": "salary", "permlevel": 1}
],
"permissions": [
{"role": "Employee", "permlevel": 0, "read": 1},
{"role": "HR Manager", "permlevel": 0, "read": 1, "write": 1},
{"role": "HR Manager", "permlevel": 1, "read": 1, "write": 1}
]
}
Rule: Levels do NOT imply hierarchy. Level 2 is not "higher" than level 1. They are independent field groups.
Data Masking [v16+]
Fields with mask=1 show masked values (e.g., ****, +91-811XXXXXXX) to users without mask permission.
{
"fieldname": "phone_number", "fieldtype": "Data", "mask": 1
}
Grant mask permission to roles that MUST see unmasked values:
{"role": "HR Manager", "permlevel": 0, "read": 1, "mask": 1}
CRITICAL: Data masking does NOT apply to frappe.db.sql() or Query Reports with raw SQL. You MUST mask manually in custom SQL queries.
Permission Hooks
has_permission: Custom Deny Logic
Can only deny access. NEVER returns True to grant. ALWAYS returns None to continue standard checks.
has_permission = {
"Sales Order": "myapp.permissions.check_order_permission"
}
def check_order_permission(doc, ptype, user):
if ptype == "write" and doc.docstatus == 2:
if "Sales Manager" not in frappe.get_roles(user):
return False
return None
permission_query_conditions: Filter List Queries
Returns SQL WHERE clause fragment. Only affects get_list(), NOT get_all().
permission_query_conditions = {
"Customer": "myapp.permissions.customer_query"
}
def customer_query(user):
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
return f"`tabCustomer`.owner = {frappe.db.escape(user)}"
ALWAYS use frappe.db.escape() — NEVER use string concatenation with raw user input.
get_list vs get_all
| Method | User Permissions | Query Hook | Use For |
|---|
frappe.get_list() | Applied | Applied | User-facing queries |
frappe.get_all() | Ignored | Ignored | System/background queries |
ALWAYS use get_list() when returning data to users. get_all() bypasses ALL permission filtering.
Common Patterns
Owner-Only Edit
{"role": "Sales User", "read": 1, "write": 1, "create": 1, "if_owner": 1}
Role-Restricted Endpoint
@frappe.whitelist()
def sensitive_action():
frappe.only_for(["Manager", "Administrator"])
Bypass Permissions (Document Why!)
doc.flags.ignore_permissions = True
doc.save()
doc.save(ignore_permissions=True)
doc.insert(ignore_permissions=True)
Critical Rules
- ALWAYS use
frappe.has_permission() — NEVER check roles directly for access control
- ALWAYS use
frappe.get_list() for user-facing queries — NEVER get_all()
- ALWAYS escape SQL in query hooks —
frappe.db.escape(user)
- ALWAYS prefix table names in query hooks —
`tabDocType`.fieldname
- ALWAYS return
None in has_permission hooks by default — NEVER True
- ALWAYS clear cache after permission changes —
frappe.clear_cache()
- ALWAYS document
ignore_permissions usage with a comment
- NEVER throw errors in
has_permission hooks — return False to deny
- NEVER grant permlevel 1+ without granting permlevel 0 first
- NEVER assume data masking applies to custom SQL queries [v16+]
Anti-Patterns
| Do NOT | Do Instead |
|---|
if "Role" in frappe.get_roles() for access | frappe.has_permission(dt, ptype) |
frappe.get_all() for user queries | frappe.get_list() |
return True in has_permission hook | return None |
f"owner = '{user}'" in SQL | f"owner = {frappe.db.escape(user)}" |
frappe.throw() in permission hooks | return False |
frappe.db.set_value() for user-facing updates | doc.save() with permission check |
| Sensitive data in error messages | Generic frappe.PermissionError |
Version Differences
| Feature | v14 | v15 | v16 |
|---|
select permission | Yes | Yes | Yes |
Desk User role | No | Yes | Yes |
Data Masking (mask field) | No | No | Yes |
mask permission type | No | No | Yes |
| Custom Permission Types | No | No | Experimental |
Permission Precedence
- Administrator — ALWAYS has all permissions (cannot be restricted)
- Role Permissions — Based on assigned roles
- User Permissions — Restricts to specific document values
- has_permission hook — Can only deny (any
False = denied)
- Sharing — Grants access to shared documents
- if_owner — Further restricts to owned documents
Reference Files
Related Skills
frappe-core-database — Database operations that respect permissions
frappe-core-api — API endpoints with permission checks
frappe-syntax-controllers — Controller permission validation
frappe-syntax-hooks — Hook configuration patterns
Verified against Frappe docs 2026-03-20 | Frappe v14/v15/v16