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.md
source content

Frappe Permissions

Deterministic patterns for the five-layer Frappe permission system.


Permission Layers

LayerControlsConfigured ViaVersion
Role PermissionsWhat users CAN doDocType permissions tableAll
User PermissionsWHICH records users seeUser Permission DocTypeAll
Perm LevelsWHICH fields users see/editField
permlevel
property
All
Permission HooksCustom deny logic
hooks.py
All
Data MaskingMasked field valuesField
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

TypeAPI CheckApplies 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/AReport Builder access
export
N/AExcel/CSV export
import
N/AData Import Tool
share
N/AShare with other users
print
N/APrint/PDF generation
email
N/ASend email
mask
Role permission for unmasked viewData Masking [v16+]

Automatic Roles

RoleAssigned ToNotes
Guest
Everyone (including anonymous)Public pages
All
All registered usersBasic authenticated access
Administrator
Only the Administrator userALWAYS 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 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

MethodUser PermissionsQuery HookUse For
frappe.get_list()
AppliedAppliedUser-facing queries
frappe.get_all()
IgnoredIgnoredSystem/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

  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 NOTDo 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 messagesGeneric
frappe.PermissionError

Version Differences

Featurev14v15v16
select
permission
YesYesYes
Desk User
role
NoYesYes
Data Masking (
mask
field)
NoNoYes
mask
permission type
NoNoYes
Custom Permission TypesNoNoExperimental

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

FileContents
permission-types-reference.mdAll permission types with options
permission-api-reference.mdComplete API with all signatures
permission-hooks-reference.mdHook patterns and examples
examples.mdWorking implementation examples
anti-patterns.mdCommon mistakes and fixes

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