Skip to content
erpnext-permissions logo

ERPNext Permissions Skill

erpnext-permissions

Complete guide for Frappe/ERPNext permission system - roles, user permissions, perm levels, data masking, and permission hooks

SKILL.md

Full skill instructions

ERPNext Permissions Skill

Deterministic patterns for implementing robust permission systems in Frappe/​ERPNext applications.


Overview

Frappe's permission system has five layers:

LayerControlsConfigured ViaVersion
Role PermissionsWhat users CAN doDocType permissions tableAll
User PermissionsWHICH documents users seeUser Permission recordsAll
Perm LevelsWHICH fields users seeField permlevel propertyAll
Permission HooksCustom logichooks.pyAll
Data MaskingMASKED field valuesField mask propertyv16+

Quick Reference

Permission Types

TypeCheckFor
readfrappe.has_permission(dt, "read")View document
writefrappe.has_permission(dt, "write")Edit document
createfrappe.has_permission(dt, "create")Create new
deletefrappe.has_permission(dt, "delete")Delete
submitfrappe.has_permission(dt, "submit")Submit (submittable only)
cancelfrappe.has_permission(dt, "cancel")Cancel
selectfrappe.has_permission(dt, "select")Select in Link (v14+)
maskRole permission for unmasked viewView unmasked data (v16+)

Automatic Roles

RoleAssigned To
GuestEveryone (including anonymous)
AllAll registered users
AdministratorOnly Administrator user
Desk UserSystem Users (v15+)

Essential API

Check Permission

# DocType level
frappe.has_permission("Sales Order", "write")

# Document level
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="[email protected]")

# Throw on denial
frappe.has_permission("Sales Order", "delete", throw=True)

# On document instance
doc = frappe.get_doc("Sales Order", "SO-00001")
if doc.has_permission("write"):
    doc.status = "Approved"
    doc.save()

# Raise error if no permission
doc.check_permission("write")

Get Permissions

from frappe.permissions import get_doc_permissions

# Get all permissions for document
perms = get_doc_permissions(doc)
# {'read': 1, 'write': 1, 'create': 0, 'delete': 0, ...}

User Permissions

from frappe.permissions import add_user_permission, remove_user_permission

# Restrict user to specific company
add_user_permission(
    doctype="Company",
    name="My Company",
    user="[email protected]",
    is_default=1
)

# Remove restriction
remove_user_permission("Company", "My Company", "[email protected]")

# Get user's permissions
from frappe.permissions import get_user_permissions
perms = get_user_permissions("[email protected]")

Sharing

from frappe.share import add as add_share

# Share document with user
add_share(
    doctype="Sales Order",
    name="SO-00001",
    user="[email protected]",
    read=1,
    write=1
)

Data Masking (v16+)

Data Masking protects sensitive field values while keeping fields visible. Users without mask permission see masked values (e.g., ****, +91-811XXXXXXX).

Use Cases

  • HR: Show employee details but mask salary amounts
  • Support: Show phone numbers partially masked
  • Finance: Show bank account fields without full numbers

Enable Data Masking

Via DocType (Developer Mode) or Customize Form:

{
  "fieldname": "phone_number",
  "fieldtype": "Data",
  "options": "Phone",
  "mask": 1
}

Supported Field Types:

  • Data, Date, Datetime
  • Currency, Float, Int, Percent
  • Phone, Password
  • Link, Dynamic Link
  • Select, Read Only, Duration

Configure Permission

Add mask permission to roles that should see unmasked data:

{
  "permissions": [
    {"role": "Employee", "permlevel": 0, "read": 1},
    {"role": "HR Manager", "permlevel": 0, "read": 1, "mask": 1}
  ]
}

How It Works

┌─────────────────────────────────────────────────────────────────────┐
│ DATA MASKING FLOW                                                   │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  1. Field has mask=1 in DocField configuration                      │
│                                                                     │
│  2. System checks: meta.has_permlevel_access_to(                    │
│        fieldname=df.fieldname,                                      │
│        df=df,                                                       │
│        permission_type="mask"                                       │
│     )                                                               │
│                                                                     │
│  3. If user LACKS mask permission:                                  │
│     └─► Value automatically masked in:                              │
│         • Form views                                                │
│         • List views                                                │
│         • Report views                                              │
│         • API responses (/​api/​resource/, /​api/​method/)              │
│                                                                     │
│  4. If user HAS mask permission:                                    │
│     └─► Full value displayed                                        │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

⚠️ Critical: Custom SQL Queries

Data Masking does NOT apply to:

  • Custom SQL queries
  • Query Reports using raw SQL
  • Direct frappe.db.sql() calls

You must implement masking manually:

def get_customer_report(filters):
    data = frappe.db.sql("""
        SELECT name, phone, email FROM tabCustomer
    """, as_dict=True)
    
    # Manual masking for users without permission
    if not frappe.has_permission("Customer", "mask"):
        for row in data:
            if row.phone:
                row.phone = mask_phone(row.phone)
    
    return data

def mask_phone(phone):
    """Mask phone number: +91-81123XXXXX"""
    if len(phone) > 5:
        return phone[:6] + "X" * (len(phone) - 6)
    return "****"

Permission Hooks

has_permission Hook

Add custom permission logic. Can only deny, not grant.

# hooks.py
has_permission = {
    "Sales Order": "myapp.permissions.check_order_permission"
}
# myapp/​permissions.py
def check_order_permission(doc, ptype, user):
    """
    Returns:
        None: Continue standard checks
        False: Deny permission
    """
    # Deny editing cancelled orders for non-managers
    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 Hook

Filter list queries. Only affects get_list(), NOT get_all().

# hooks.py
permission_query_conditions = {
    "Customer": "myapp.permissions.customer_query"
}
# myapp/​permissions.py
def customer_query(user):
    """Return SQL WHERE clause fragment."""
    if not user:
        user = frappe.session.user
    
    # Managers see all
    if "Sales Manager" in frappe.get_roles(user):
        return ""
    
    # Others see only their customers
    return f"`tabCustomer`.owner = {frappe.db.escape(user)}"

CRITICAL: Always use frappe.db.escape() - never string concatenation!


get_list vs get_all

MethodUser PermissionsQuery Hook
frappe.get_list()✅ Applied✅ Applied
frappe.get_all()❌ Ignored❌ Ignored
# User-facing query - respects permissions
docs = frappe.get_list("Sales Order", filters={"status": "Open"})

# System query - bypasses permissions
docs = frappe.get_all("Sales Order", filters={"status": "Open"})

Field-Level Permissions (Perm Levels)

Configure Field

{
  "fieldname": "salary",
  "fieldtype": "Currency",
  "permlevel": 1
}

Configure Role Access

{
  "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: Level 0 MUST be granted before higher levels.


Decision Tree

Need to control access?
├── To entire DocType → Role Permissions
├── To specific documents → User Permissions
├── To specific fields (hide completely) → Perm Levels
├── To specific fields (show masked) → Data Masking (v16+)
├── With custom logic → has_permission hook
└── For list queries → permission_query_conditions hook

Checking permissions in code?
├── Before action → frappe.has_permission() or doc.has_permission()
├── Raise error → doc.check_permission() or throw=True
└── Bypass needed → doc.flags.ignore_permissions = True (document why!)

Common Patterns

Owner-Only Edit

{
  "role": "Sales User",
  "read": 1, "write": 1, "create": 1,
  "if_owner": 1
}

Check Before Action

@frappe.whitelist()
def approve_order(order_name):
    doc = frappe.get_doc("Sales Order", order_name)
    
    if not doc.has_permission("write"):
        frappe.throw(_("No permission"), frappe.PermissionError)
    
    doc.status = "Approved"
    doc.save()

Role-Restricted Endpoint

@frappe.whitelist()
def sensitive_action():
    frappe.only_for(["Manager", "Administrator"])
    # Only reaches here if user has role

Critical Rules

  1. ALWAYS use permission API - Not role checks
  2. ALWAYS escape SQL - frappe.db.escape(user)
  3. ALWAYS use get_list - For user-facing queries
  4. ALWAYS return None - In has_permission hooks (not True)
  5. ALWAYS document - When using ignore_permissions
  6. ALWAYS clear cache - After permission changes: frappe.clear_cache()
  7. ALWAYS mask manually - In custom SQL queries (v16+)

Anti-Patterns

❌ Don't✅ Do
if "Role" in frappe.get_roles()frappe.has_permission(dt, ptype)
frappe.get_all() for user queriesfrappe.get_list()
return True in has_permissionreturn None
f"owner = '{user}'"f"owner = {frappe.db.escape(user)}"
frappe.throw() in hooksreturn False
Assume masking in custom SQLImplement masking manually

Version Differences

Featurev14v15v16
select permission✅✅✅
Desk User role❌✅✅
Custom Permission Types❌❌✅ (experimental)
Data Masking❌❌✅
mask permission type❌❌✅

Debugging

# Enable debug output
frappe.has_permission("Sales Order", "read", doc, debug=True)

# View logs
print(frappe.local.permission_debug_log)

# Check user's effective permissions
from frappe.permissions import get_doc_permissions
perms = get_doc_permissions(doc, user="[email protected]")

Reference Files

See references/ folder for:

  • permission-types-reference.md - All permission types
  • permission-api-reference.md - Complete API reference
  • permission-hooks-reference.md - Hook patterns
  • examples.md - Working examples
  • anti-patterns.md - Common mistakes

Related Skills

  • erpnext-database - Database operations that respect permissions
  • erpnext-syntax-controllers - Controller permission checks
  • erpnext-syntax-hooks - Hook configuration

Last updated: 2026-01-18 | Frappe v14/​v15/​v16

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View