Frappe_Claude_Skill_Package frappe-core-permissions
install
source · Clone the upstream repo
git clone https://github.com/OpenAEC-Foundation/Frappe_Claude_Skill_Package
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/OpenAEC-Foundation/Frappe_Claude_Skill_Package "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/source/core/frappe-core-permissions" ~/.claude/skills/openaec-foundation-frappe-claude-skill-package-frappe-core-permissions && rm -rf "$T"
manifest:
skills/source/core/frappe-core-permissions/SKILL.mdsource content
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 property | All |
| Permission Hooks | Custom deny logic | | All |
| Data Masking | Masked field values | Field 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 |
|---|---|---|
| | All DocTypes |
| | All DocTypes |
| | All DocTypes |
| | All DocTypes |
| | Submittable only |
| | Submittable only |
| | Submittable only |
| | Link fields [v14+] |
| N/A | Report Builder access |
| N/A | Excel/CSV export |
| N/A | Data Import Tool |
| N/A | Share with other users |
| N/A | Print/PDF generation |
| N/A | Send email |
| Role permission for unmasked view | Data Masking [v16+] |
Automatic Roles
| Role | Assigned To | Notes |
|---|---|---|
| Everyone (including anonymous) | Public pages |
| All registered users | Basic authenticated access |
| Only the Administrator user | ALWAYS has all permissions |
| 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 default
permission_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 |
|---|---|---|---|
| Applied | Applied | User-facing queries |
| 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 roles
Bypass 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
- ALWAYS use
— NEVER check roles directly for access controlfrappe.has_permission() - ALWAYS use
for user-facing queries — NEVERfrappe.get_list()get_all() - ALWAYS escape SQL in query hooks —
frappe.db.escape(user) - ALWAYS prefix table names in query hooks —
`tabDocType`.fieldname - ALWAYS return
inNone
hooks by default — NEVERhas_permissionTrue - ALWAYS clear cache after permission changes —
frappe.clear_cache() - ALWAYS document
usage with a commentignore_permissions - NEVER throw errors in
hooks — returnhas_permission
to denyFalse - 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 |
|---|---|
for access | |
for user queries | |
in has_permission hook | |
in SQL | |
in permission hooks | |
for user-facing updates | with permission check |
| Sensitive data in error messages | Generic |
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
permission | Yes | Yes | Yes |
role | No | Yes | Yes |
Data Masking ( field) | No | No | Yes |
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
= denied)False - Sharing — Grants access to shared documents
- 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
— Database operations that respect permissionsfrappe-core-database
— API endpoints with permission checksfrappe-core-api
— Controller permission validationfrappe-syntax-controllers
— Hook configuration patternsfrappe-syntax-hooks
Verified against Frappe docs 2026-03-20 | Frappe v14/v15/v16