
Frappe Core Permissions
- 28 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-core-permissions is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-core-permissions
- AI & Agent Building
- AI-coding skill
Frappe Core Permissions by the numbers
- 28 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #9,505 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-core-permissionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 28 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
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
# DocType-level
frappe.has_permission("Sales Order", "write")
# Document-level (by name or object)
frappe.has_permission("Sales Order", "write", "SO-00001")
frappe.has_permission("Sales Order", "write", doc=doc)
# For specific user
frappe.has_permission("Sales Order", "read", user="john@example.com")
# Throw on denial
frappe.has_permission("Sales Order", "delete", throw=True)
# Debug mode — prints evaluation steps
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")
# Returns bool
if doc.has_permission("write"):
doc.status = "Approved"
doc.save()
# Raises frappe.PermissionError if denied
doc.check_permission("write")Get Effective Permissions
from frappe.permissions import get_doc_permissions
perms = get_doc_permissions(doc)
# {'read': 1, 'write': 1, 'create': 0, 'delete': 0, ...}
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
# Restrict user to one company
add_user_permission(
doctype="Company",
name="My Company",
user="john@example.com",
is_default=1, # auto-fill in new documents
applicable_for="Sales Order" # only for this DocType (optional)
)
# Remove restriction
remove_user_permission("Company", "My Company", "john@example.com")
# Query current restrictions
from frappe.permissions import get_user_permissions
perms = get_user_permissions("john@example.com")
# {"Company": [{"doc": "My Company", "is_default": 1}], ...}---
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")
# Share with everyone
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.
# hooks.py
has_permission = {
"Sales Order": "myapp.permissions.check_order_permission"
}# myapp/permissions.py
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 # ALWAYS return None by defaultpermission_query_conditions: Filter List Queries
Returns SQL WHERE clause fragment. Only affects get_list(), NOT get_all().
# hooks.py
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"])
# Only reaches here if user has one of these rolesBypass Permissions (Document Why!)
# On document — ALWAYS add a comment explaining the reason
doc.flags.ignore_permissions = True
doc.save()
# On method call
doc.save(ignore_permissions=True)
doc.insert(ignore_permissions=True)---
Critical Rules
1. ALWAYS use frappe.has_permission() — NEVER check roles directly for access control 2. ALWAYS use frappe.get_list() for user-facing queries — NEVER get_all() 3. ALWAYS escape SQL in query hooks — frappe.db.escape(user) 4. ALWAYS prefix table names in query hooks — ` tabDocType.fieldname 5. **ALWAYS** return None in has_permission hooks by default — NEVER True 6. **ALWAYS** clear cache after permission changes — frappe.clear_cache() 7. **ALWAYS** document ignore_permissions usage with a comment 8. **NEVER** throw errors in has_permission hooks — return False` to deny 9. NEVER grant permlevel 1+ without granting permlevel 0 first 10. 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
1. Administrator — ALWAYS has all permissions (cannot be restricted) 2. Role Permissions — Based on assigned roles 3. User Permissions — Restricts to specific document values 4. has_permission hook — Can only deny (any False = denied) 5. Sharing — Grants access to shared documents 6. if_owner — Further restricts to owned documents
---
Reference Files
| File | Contents |
|---|---|
| permission-types-reference.md | All permission types with options |
| permission-api-reference.md | Complete API with all signatures |
| permission-hooks-reference.md | Hook patterns and examples |
| examples.md | Working implementation examples |
| anti-patterns.md | Common mistakes and fixes |
Related Skills
frappe-core-database— Database operations that respect permissionsfrappe-core-api— API endpoints with permission checksfrappe-syntax-controllers— Controller permission validationfrappe-syntax-hooks— Hook configuration patterns
---
Verified against Frappe docs 2026-03-20 | Frappe v14/v15/v16
Permission Anti-Patterns
Common permission mistakes and their correct alternatives.
---
1. Checking Role Instead of Permission
# WRONG — bypasses User Permissions, hooks, and if_owner
if "Sales Manager" in frappe.get_roles():
doc.save(ignore_permissions=True)
# CORRECT — uses full permission system
if not doc.has_permission("write"):
frappe.throw(_("No permission"), frappe.PermissionError)
doc.save()---
2. Using get_all for User-Facing Queries
# WRONG — ignores User Permissions and query hooks
orders = frappe.get_all("Sales Order", filters={"status": "Open"})
# CORRECT — respects all permission layers
orders = frappe.get_list("Sales Order", filters={"status": "Open"})---
3. SQL Injection in Query Hook
# WRONG — vulnerable to injection
def bad_query(user):
return f"owner = '{user}'"
# CORRECT — escaped
def good_query(user):
if not user:
user = frappe.session.user
return f"`tabCustomer`.owner = {frappe.db.escape(user)}"---
4. Missing Table Prefix in Query Hook
# WRONG — ambiguous column in JOINs
def bad_query(user):
return f"owner = {frappe.db.escape(user)}"
# CORRECT — explicit table prefix
def good_query(user):
return f"`tabSales Order`.owner = {frappe.db.escape(user)}"---
5. Throwing Errors in has_permission Hook
# WRONG — interrupts permission evaluation, may expose info
def bad_permission(doc, ptype, user):
frappe.throw("Not allowed!")
# CORRECT — return False to deny
def good_permission(doc, ptype, user):
return False---
6. Returning True in has_permission Hook
# WRONG — True does NOT grant permission in most versions
def bad_permission(doc, ptype, user):
if is_special_user(user):
return True # Has NO effect!
return None
# CORRECT — hooks can only deny, not grant
def good_permission(doc, ptype, user):
if should_deny(doc, user):
return False
return None # Let standard checks decide---
7. ignore_permissions Without Documentation
# WRONG — no explanation why permissions are bypassed
doc.save(ignore_permissions=True)
# CORRECT — documented reason
# System action: auto-processed by scheduler job (runs as Administrator)
doc.add_comment("Info", "System: Auto-processed by scheduler")
doc.flags.ignore_permissions = True
doc.save()---
8. Granting Perm Level 1+ Without Level 0
// WRONG — causes error
{"role": "HR User", "permlevel": 1, "read": 1}
// CORRECT — level 0 first
[
{"role": "HR User", "permlevel": 0, "read": 1},
{"role": "HR User", "permlevel": 1, "read": 1, "write": 1}
]---
9. Using db_set/set_value for User-Facing Updates
# WRONG — bypasses permissions, validation, and hooks
frappe.db.set_value("DocType", doc_name, "field", "value")
# CORRECT — respects all checks
doc = frappe.get_doc("DocType", doc_name)
doc.check_permission("write")
doc.field = "value"
doc.save()frappe.db.set_value() bypasses: permission checks, validate methods, before/after save hooks, and child table validation.
---
10. Not Clearing Cache After Permission Changes
# WRONG — changes may not take effect
from frappe.permissions import add_permission
add_permission("Sales Order", "New Role")
# CORRECT — clear cache
add_permission("Sales Order", "New Role")
frappe.clear_cache()---
11. Sensitive Data in Error Messages
# WRONG — leaks information
frappe.throw(f"Cannot view {doc.employee_name}'s salary: {doc.salary}")
# CORRECT — generic error
frappe.throw(_("Permission denied"), frappe.PermissionError)---
12. Hardcoding Administrator Bypass
# WRONG — security risk
if frappe.session.user == "Administrator":
perform_operation()
# CORRECT — use permission system
if not frappe.has_permission("Sensitive DocType", "write"):
frappe.throw(_("Access Denied"), frappe.PermissionError)
perform_operation()---
13. Assuming Data Masking in Custom SQL [v16+]
# WRONG — masking does NOT apply to raw SQL
data = frappe.db.sql("SELECT phone FROM tabCustomer", as_dict=True)
# CORRECT — manual masking
data = frappe.db.sql("SELECT phone FROM tabCustomer", as_dict=True)
if not frappe.has_permission("Customer", "mask"):
for row in data:
if row.phone and len(row.phone) > 5:
row.phone = row.phone[:3] + "X" * (len(row.phone) - 3)---
Pre-Deploy Checklist
- [ ] Using
has_permission()instead of role checks? - [ ] Using
get_list()instead ofget_all()for user queries? - [ ] All SQL inputs escaped with
frappe.db.escape()? - [ ] Table prefixes used in query conditions?
- [ ]
ignore_permissionsusage documented with comments? - [ ] Permission cache cleared after programmatic changes?
- [ ] Error messages do NOT leak sensitive data?
- [ ] Level 0 granted before higher perm levels?
- [ ] Hooks return
NoneorFalse, NEVER throw errors? - [ ] Tested with non-admin user account?
Permission Examples
Working implementation examples for common permission scenarios.
---
Example 1: Permission Check Before Action
@frappe.whitelist()
def update_order_status(order_name, new_status):
doc = frappe.get_doc("Sales Order", order_name)
doc.check_permission("write") # Raises PermissionError if denied
doc.status = new_status
doc.save()
return {"status": "success"}---
Example 2: Owner-Only Edit Configuration
{
"permissions": [
{
"role": "Sales User", "permlevel": 0,
"read": 1, "write": 1, "create": 1, "if_owner": 1
},
{
"role": "Sales Manager", "permlevel": 0,
"read": 1, "write": 1, "create": 1, "delete": 1, "if_owner": 0
}
]
}- Sales User: read/write/create only their own documents
- Sales Manager: full CRUD on all documents
---
Example 3: Field-Level Permissions (Perm Levels)
Hide salary from regular users, show to HR only:
{
"fields": [
{"fieldname": "employee_name", "permlevel": 0},
{"fieldname": "department", "permlevel": 0},
{"fieldname": "salary", "permlevel": 1},
{"fieldname": "bank_account", "permlevel": 1}
],
"permissions": [
{"role": "Employee Self Service", "permlevel": 0, "read": 1},
{"role": "HR Manager", "permlevel": 0, "read": 1, "write": 1},
{"role": "HR Manager", "permlevel": 1, "read": 1, "write": 1}
]
}---
Example 4: Multi-Company User Restrictions
def setup_user_company_restriction(user, company):
from frappe.permissions import add_user_permission
add_user_permission(
doctype="Company",
name=company,
user=user,
ignore_permissions=True,
is_default=1
)
# Bulk setup
for user, company in [("john@ex.com", "Company A"), ("jane@ex.com", "Company B")]:
setup_user_company_restriction(user, company)---
Example 5: Custom Permission Hook — Invoice Age Lock
Deny editing invoices older than 30 days for non-accountants:
# hooks.py
has_permission = {
"Sales Invoice": "myapp.permissions.invoice_permission"
}# myapp/permissions.py
from frappe.utils import date_diff, today
def invoice_permission(doc, ptype, user):
if ptype not in ("write", "cancel"):
return None
if "Accounts Manager" in frappe.get_roles(user):
return None
if doc.posting_date and date_diff(today(), doc.posting_date) > 30:
return False
return None---
Example 6: Territory-Based Query Conditions
# hooks.py
permission_query_conditions = {
"Customer": "myapp.permissions.customer_territory_query"
}def customer_territory_query(user):
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
territories = frappe.get_all(
"User Permission",
filters={"user": user, "allow": "Territory", "apply_to_all_doctypes": 1},
pluck="for_value"
)
if not territories:
return ""
escaped = [frappe.db.escape(t) for t in territories]
return f"`tabCustomer`.territory IN ({', '.join(escaped)})"---
Example 7: Document Sharing
from frappe.share import add as add_share, remove as remove_share
def share_for_review(order_name, reviewer_email):
add_share("Sales Order", order_name, reviewer_email,
read=1, write=0, share=0, notify=1)
def revoke_access(order_name, reviewer_email):
remove_share("Sales Order", order_name, reviewer_email)---
Example 8: Controller Permission Validation
class MyDocType(Document):
def validate(self):
if self.has_value_changed("status") and self.status == "Approved":
if not frappe.has_permission(self.doctype, "write", self):
frappe.throw(_("No permission to approve"), frappe.PermissionError)
if self.amount > 50000 and "Finance Manager" not in frappe.get_roles():
frappe.throw(_("Finance Manager required for amounts > 50,000"))---
Example 9: Programmatic Role Setup (App Install)
def after_install():
if not frappe.db.exists("Role", "Custom Reviewer"):
role = frappe.new_doc("Role")
role.role_name = "Custom Reviewer"
role.desk_access = 1
role.insert(ignore_permissions=True)
from frappe.permissions import add_permission, update_permission_property
add_permission("Sales Order", "Custom Reviewer", permlevel=0)
update_permission_property("Sales Order", "Custom Reviewer", 0, "read", 1)
update_permission_property("Sales Order", "Custom Reviewer", 0, "report", 1)
update_permission_property("Sales Order", "Custom Reviewer", 0, "export", 1)
frappe.clear_cache()---
Example 10: Role-Restricted API Endpoint
@frappe.whitelist()
def get_confidential_report():
frappe.only_for(["Sales Manager", "System Manager"])
return generate_report()
@frappe.whitelist()
def approve_all_pending():
frappe.only_for(["Approver", "System Manager"])
pending = frappe.get_all("Sales Order",
filters={"status": "Pending Approval"}, pluck="name")
for name in pending:
doc = frappe.get_doc("Sales Order", name)
doc.add_comment("Info", "System: Bulk approved")
doc.flags.ignore_permissions = True # System action — bulk approval
doc.status = "Approved"
doc.save()
return {"approved": len(pending)}---
Example 11: Combined Hook System
# hooks.py
has_permission = {"Project": "myapp.permissions.project_permission"}
permission_query_conditions = {"Project": "myapp.permissions.project_query"}# myapp/permissions.py
def project_permission(doc, ptype, user):
if not user:
user = frappe.session.user
if "Projects Manager" in frappe.get_roles(user):
return None
if not frappe.db.exists("Project User", {"parent": doc.name, "user": user}):
return False
if ptype in ("read", "write"):
return None
return False
def project_query(user):
if not user:
user = frappe.session.user
if "Projects Manager" in frappe.get_roles(user):
return ""
return """EXISTS (
SELECT 1 FROM `tabProject User`
WHERE `tabProject User`.parent = `tabProject`.name
AND `tabProject User`.user = {user}
)""".format(user=frappe.db.escape(user))Permission API Reference
Complete API reference for all Frappe permission functions.
---
Core Permission Checks
frappe.has_permission()
frappe.has_permission(
doctype, # Required: DocType name
ptype="read", # Permission type (read/write/create/delete/submit/cancel/select)
doc=None, # Document name (str) or Document object
user=None, # User email (default: current session user)
throw=False, # If True, raises frappe.PermissionError on denial
debug=False, # If True, prints evaluation steps to console
parent_doctype=None # For child table permission checks
)
# Returns: boolExamples:
# DocType-level check
frappe.has_permission("Sales Order", "create")
# Document-level check (by name or object)
frappe.has_permission("Sales Order", "write", "SO-00001")
frappe.has_permission("Sales Order", "write", doc=doc_object)
# For specific user
frappe.has_permission("Sales Order", "read", user="john@example.com")
# Throw on denial
frappe.has_permission("Sales Order", "delete", throw=True)
# Debug mode — prints evaluation log
frappe.has_permission("Sales Order", "read", debug=True)
print(frappe.local.permission_debug_log)---
Document.has_permission()
doc.has_permission(
permtype="read", # Permission type
debug=False, # Print debug logs
user=None # User (default: current session user)
)
# Returns: boolNote: Respects doc.flags.ignore_permissions flag.
doc = frappe.get_doc("Sales Order", "SO-00001")
if doc.has_permission("write"):
doc.status = "Draft"
doc.save()---
Document.check_permission()
Raises frappe.PermissionError if no permission.
doc.check_permission(
permtype="read", # Permission type
permlevel=None # Optional: specific perm level
)
# Returns: None (raises frappe.PermissionError if denied)doc = frappe.get_doc("Sales Order", "SO-00001")
doc.check_permission("write") # Raises error if denied
doc.status = "Closed"
doc.save()---
Permission Query Functions
get_doc_permissions()
Get all effective permissions for a document.
from frappe.permissions import get_doc_permissions
perms = get_doc_permissions(doc, user=None, ptype=None)
# Returns: dict {"read": 1, "write": 1, "create": 0, "delete": 0, ...}get_role_permissions()
Get role-based permissions (without user permissions applied).
from frappe.permissions import get_role_permissions
meta = frappe.get_meta("Sales Order")
perms = get_role_permissions(meta, user=None)
# Returns: dict {"read": 1, "write": 0, ...}---
User Permission Functions
add_user_permission()
from frappe.permissions import add_user_permission
add_user_permission(
doctype, # DocType to restrict (e.g., "Company")
name, # Value to allow (e.g., "My Company")
user, # User email
ignore_permissions=False,
applicable_for=None, # Apply only to specific DocType (optional)
is_default=0, # Use as default value in new documents
hide_descendants=0 # For tree DocTypes — hide child nodes
)remove_user_permission()
from frappe.permissions import remove_user_permission
remove_user_permission(doctype, name, user)get_user_permissions()
from frappe.permissions import get_user_permissions
perms = get_user_permissions(user=None)
# Returns: dict by doctype
# {"Company": [{"doc": "My Company", "is_default": 1}], ...}clear_user_permissions_for_doctype()
from frappe.permissions import clear_user_permissions_for_doctype
clear_user_permissions_for_doctype(doctype, user=None)
# Clears all user permissions for the doctype (optionally for specific user)---
Role Permission Management
add_permission()
from frappe.permissions import add_permission
add_permission(doctype, role, permlevel=0)update_permission_property()
from frappe.permissions import update_permission_property
update_permission_property(doctype, role, permlevel, ptype, value)
# Example:
update_permission_property("Sales Order", "Sales User", 0, "write", 1)
update_permission_property("Sales Order", "Sales User", 0, "if_owner", 1)remove_permission()
from frappe.permissions import remove_permission
remove_permission(doctype, role, permlevel=0)reset_perms()
Reset permissions to DocType defaults (removes customizations).
from frappe.permissions import reset_perms
reset_perms(doctype)---
Sharing Functions
frappe.share.add()
from frappe.share import add as add_share
add_share(
doctype,
name,
user=None, # User email (required unless everyone=1)
read=1,
write=0,
share=0, # Allow re-sharing
everyone=0, # Share with all users
notify=0 # Send email notification
)frappe.share.remove()
from frappe.share import remove as remove_share
remove_share(doctype, name, user)frappe.share.get_shared()
from frappe.share import get_shared
users = get_shared(doctype, name)
# Returns: list of user emails with shared access---
Utility Functions
frappe.get_roles()
roles = frappe.get_roles(user=None)
# Returns: list of role names
# ['Guest', 'All', 'Sales User', 'System Manager']frappe.only_for()
Restrict function execution to specific roles. Raises frappe.PermissionError if user lacks all listed roles.
@frappe.whitelist()
def sensitive_action():
frappe.only_for(["Sales Manager", "System Manager"])
# Only reaches here if user has at least one of these roles---
Bypass Permissions
ignore_permissions Flag
# On document flags
doc.flags.ignore_permissions = True
doc.save()
# On save/insert method
doc.save(ignore_permissions=True)
doc.insert(ignore_permissions=True)Set User Temporarily
original_user = frappe.session.user
frappe.set_user("Administrator")
# ... privileged operations ...
frappe.set_user(original_user)System Flags
frappe.flags.in_setup_wizard = True
frappe.flags.in_install = True
frappe.flags.in_migrate = TrueALWAYS document the reason when bypassing permissions. NEVER use these without clear justification.
---
Cache Management
ALWAYS clear cache after modifying permissions programmatically:
frappe.clear_cache()
# Or for specific doctype:
frappe.clear_cache(doctype="Sales Order")Permission Hooks Reference
Complete reference for has_permission and permission_query_conditions hooks.
---
has_permission Hook
Purpose
Add custom permission logic for a DocType. Can only deny permission, NEVER grant it.
Configuration
# hooks.py
has_permission = {
"Sales Order": "myapp.permissions.sales_order_permission",
"Customer": "myapp.permissions.customer_permission"
}Function Signature
def my_permission_check(doc, ptype, user):
"""
Args:
doc: Document object being checked
ptype: Permission type (read, write, create, delete, submit, cancel)
user: User email being checked
Returns:
None: No effect — continue standard permission checks
False: DENY permission
True: No effect in most versions (NEVER rely on this to grant)
"""
passExamples
Deny Editing Cancelled Documents
def sales_order_permission(doc, ptype, user):
if doc.docstatus == 2 and ptype == "write":
if "Sales Manager" not in frappe.get_roles(user):
return False
return NoneTime-Based Access Control
def time_based_permission(doc, ptype, user):
if ptype == "write":
hour = frappe.utils.now_datetime().hour
if hour < 9 or hour > 18:
if "System Manager" not in frappe.get_roles(user):
return False
return NoneHierarchical Approval
def approval_permission(doc, ptype, user):
if ptype == "write" and doc.status == "Pending Approval":
if doc.grand_total > 100000 and user != doc.department_head:
return False
return NoneCritical Rules
1. ALWAYS return None by default — to continue standard checks 2. NEVER return True to grant — it has no effect in most versions 3. NEVER throw errors — return False to deny instead 4. ALWAYS check ptype — do NOT apply write logic to read checks 5. Keep hooks fast — they are called frequently during permission evaluation
---
permission_query_conditions Hook
Purpose
Add WHERE clause conditions to frappe.get_list() queries for row-level filtering.
Configuration
# hooks.py
permission_query_conditions = {
"ToDo": "myapp.permissions.todo_query",
"Customer": "myapp.permissions.customer_query"
}Function Signature
def my_query_conditions(user):
"""
Args:
user: User email (can be None — ALWAYS default to session user)
Returns:
str: Valid SQL WHERE clause fragment
"": Empty string for no restriction (NEVER return None)
"""
passExamples
Owner-Based Filter
def todo_query(user):
if not user:
user = frappe.session.user
return """`tabToDo`.owner = {user} OR `tabToDo`.assigned_by = {user}""".format(
user=frappe.db.escape(user)
)Role-Based Filter
def customer_query(user):
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
return "`tabCustomer`.owner = {user}".format(user=frappe.db.escape(user))Territory-Based Filter
def customer_territory_query(user):
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
territories = frappe.get_all(
"User Permission",
filters={"user": user, "allow": "Territory"},
pluck="for_value"
)
if not territories:
return ""
territory_list = ", ".join([frappe.db.escape(t) for t in territories])
return f"`tabCustomer`.territory IN ({territory_list})"Subquery Filter
def project_query(user):
if not user:
user = frappe.session.user
if "Projects Manager" in frappe.get_roles(user):
return ""
return """EXISTS (
SELECT 1 FROM `tabProject User`
WHERE `tabProject User`.parent = `tabProject`.name
AND `tabProject User`.user = {user}
)""".format(user=frappe.db.escape(user))Critical Rules
1. ALWAYS escape user input — frappe.db.escape(user) 2. ALWAYS use backtick table prefixes — ` tabDocType.fieldname 3. **ALWAYS** handle None user — default to frappe.session.user 4. **ALWAYS** return empty string for no restriction — NEVER return None 5. Only affects get_list() — does NOT affect get_all()`
---
get_list vs get_all Behavior
| Method | User Permissions | permission_query_conditions |
|---|---|---|
frappe.get_list() | Applied | Applied |
frappe.get_all() | Ignored | Ignored |
frappe.db.get_list() | Applied | Applied |
frappe.db.get_all() | Ignored | Ignored |
---
Combining Both Hooks
ALWAYS implement both hooks together for consistent behavior:
# hooks.py
has_permission = {
"Project": "myapp.permissions.project_permission"
}
permission_query_conditions = {
"Project": "myapp.permissions.project_query"
}has_permission controls single-document access. permission_query_conditions controls list-view filtering. If they apply different logic, users see inconsistent results.
---
Hook Registration Order
1. Hooks from all installed apps are collected 2. Order follows app installation order in apps.txt 3. For has_permission: ALL hooks must pass (any False = denied) 4. For permission_query_conditions: conditions are AND-ed together
---
Common Mistakes
SQL Injection
# WRONG — vulnerable
def bad_query(user):
return f"owner = '{user}'"
# CORRECT — escaped
def good_query(user):
return f"`tabCustomer`.owner = {frappe.db.escape(user)}"Missing Table Prefix
# WRONG — ambiguous column in joins
def bad_query(user):
return f"owner = {frappe.db.escape(user)}"
# CORRECT — explicit table
def good_query(user):
return f"`tabSales Order`.owner = {frappe.db.escape(user)}"Throwing in Hook
# WRONG — interrupts evaluation
def bad_permission(doc, ptype, user):
frappe.throw("Not allowed!")
# CORRECT — return False to deny
def good_permission(doc, ptype, user):
return FalsePermission Types Reference
Complete reference for all Frappe permission types, options, and levels.
---
Document-Level Permissions
| Permission | API Check | Description | Applies To |
|---|---|---|---|
read | frappe.has_permission(dt, "read") | View document | All DocTypes |
write | frappe.has_permission(dt, "write") | Edit/update document | All DocTypes |
create | frappe.has_permission(dt, "create") | Create new document | All DocTypes |
delete | frappe.has_permission(dt, "delete") | Delete document | All DocTypes |
select | frappe.has_permission(dt, "select") | Select in Link field [v14+] | All DocTypes |
Workflow Permissions (Submittable DocTypes Only)
| Permission | API Check | Description | Prerequisite |
|---|---|---|---|
submit | frappe.has_permission(dt, "submit") | Submit document | is_submittable = 1 |
cancel | frappe.has_permission(dt, "cancel") | Cancel submitted doc | is_submittable = 1 |
amend | frappe.has_permission(dt, "amend") | Amend cancelled doc | is_submittable = 1 |
Action Permissions
| Permission | Description | Notes |
|---|---|---|
report | Access Report Builder for DocType | UI-level only |
export | Export records to Excel/CSV | UI-level only |
import | Import records via Data Import | UI-level only |
share | Share document with other users | UI-level only |
print | Print document or generate PDF | UI-level only |
email | Send email for document | UI-level only |
Data Masking Permission [v16+]
| Permission | Description | Notes |
|---|---|---|
mask | View unmasked field values | Only for fields with mask=1 |
---
Permission Options
if_owner
Restricts permission to documents created by the user.
{
"role": "Sales User",
"permlevel": 0,
"read": 1,
"write": 1,
"if_owner": 1
}Effect: Sales User can only read/write documents they created (owner field matches).
set_user_permissions
Allows the user to create User Permission records for other users on this DocType.
{
"role": "Sales Manager",
"permlevel": 0,
"set_user_permissions": 1
}---
Permission Levels (Perm Levels)
Concept
Group fields into levels (0-9) for separate permission control per role.
Configuration
// Field definition
{"fieldname": "salary", "fieldtype": "Currency", "permlevel": 1}
// Role permission
{"role": "HR Manager", "permlevel": 1, "read": 1, "write": 1}Rules
1. Level 0 MUST be granted before higher levels — ALWAYS 2. Levels do NOT imply hierarchy (level 2 is NOT "higher" than level 1) 3. Levels group fields; roles grant access to groups 4. A Section Break with permlevel affects all fields in that section
Example
Field: employee_name permlevel: 0 → All roles with level 0 read
Field: phone permlevel: 0 → All roles with level 0 read
Field: salary permlevel: 1 → Only roles with level 1 read
Field: bank_account permlevel: 1 → Only roles with level 1 read
Field: performance permlevel: 2 → Only roles with level 2 read---
Automatic Roles
| Role | Assigned To | Use Case |
|---|---|---|
Guest | All users (including anonymous) | Public website pages |
All | All registered users (including website users) | Basic authenticated access |
Administrator | Only the Administrator user | Full system control |
Desk User | System Users only [v15+] | Desk/backend access |
These roles are hidden in the Role list and CANNOT be manually assigned or removed.
---
Custom Permission Types [v16+]
Creating Custom Permission
1. Enable Developer Mode 2. Create a Permission Type record (e.g., approve) 3. Export as fixture for deployment 4. Use in Role Permission Manager
Checking
if frappe.has_permission("Sales Order", "approve", doc):
approve_document(doc)---
Permission Precedence Order
1. Administrator — ALWAYS has all permissions (cannot be restricted) 2. Role Permissions — Based on assigned roles 3. User Permissions — Restricts to specific document values 4. has_permission hook — Can only deny (any False = denied) 5. Sharing — Grants access to individual shared documents 6. if_owner — Further restricts to owned documents
---
Quick Decision Table
| I want to... | Use |
|---|---|
| Control who can create/read/write/delete | Role Permissions |
| Let users only edit their own docs | if_owner option |
| Hide salary field from most users | Perm Level 1+ |
| Show masked phone numbers | Data Masking [v16+] |
| Restrict access to specific company | User Permission |
| Add custom "approve" action | Custom Permission Type [v16+] |
| Programmatically deny access | has_permission hook |
| Share one doc with one user | frappe.share.add() |