
Sf Permissions
- 33 installs
- 423 repo stars
- Updated April 27, 2026
- jaganpro/claude-code-sfskills
This is a copy of sf-permissions by jaganpro - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks.
About
sf-permissions is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- sf-permissions
- AI & Agent Building
- AI-coding skill
Sf Permissions by the numbers
- 33 all-time installs (skills.sh)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jaganpro/claude-code-sfskills --skill sf-permissionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 33 |
|---|---|
| repo stars | ★ 423 |
| Last updated | April 27, 2026 |
| Repository | jaganpro/claude-code-sfskills ↗ |
What it does
Helps with ai & agent building tasks.
Files
sf-permissions
Use this skill when the user needs permission analysis and access auditing: Permission Set / Permission Set Group hierarchy views, “who has access to X?” investigations, user-permission analysis, or permission-set metadata review.
When This Skill Owns the Task
Use sf-permissions when the work involves:
- permission set / permission set group analysis
- user access investigation
- finding which permission grants object / field / Apex / flow / tab / custom-permission access
- auditing or exporting permission configuration
- reviewing permission metadata impacts
Delegate elsewhere when the user is:
- creating new metadata definitions → sf-metadata
- deploying permission sets → sf-deploy
- analyzing Apex-managed sharing logic → sf-apex
---
Required Context to Gather First
Ask for or infer:
- target org alias
- whether the question is about an object, field, Apex class, flow, tab, custom permission, or specific user
- whether the goal is hierarchy visualization, access detection, export, or metadata generation
- whether the output should be terminal-focused or documentation-friendly
---
Recommended Workflow
1. Classify the request
| Request shape | Default capability |
|---|---|
| “who has access to X?” | permission detector |
| “what does this user have?” | user analyzer |
| “show me the hierarchy” | hierarchy viewer |
| “export this permset” | exporter |
| “generate metadata from analysis” | generator or handoff |
2. Connect to the correct org
Verify sf auth before running permission analysis.
3. Use the narrowest useful query
Prefer focused analysis over broad org-wide scans unless the user explicitly wants a full audit.
4. Render findings clearly
Use:
- ASCII tree or table output for terminal work
- Mermaid only when documentation benefit is clear
- concise summaries of which permission source grants access
5. Hand off creation or deployment work
Use:
- sf-metadata for richer metadata generation
- sf-deploy for deployment
---
High-Signal Rules
- distinguish direct Permission Set grants from grants via Permission Set Groups
- be explicit about whether access is object-level, field-level, class-level, flow-level, or custom-permission-based
- use Tooling API where required for setup entities and advanced visibility questions
- for agent access questions, verify exact agent-name matching in permission metadata
---
Output Format
When finishing, report in this order: 1. What was analyzed 2. Org / subject scope 3. Which permissions grant access 4. Whether access is direct or inherited 5. Recommended follow-up
Suggested shape:
Permission analysis: <hierarchy / detect / user / export>
Scope: <org, user, permission target>
Findings: <permsets / groups / access level>
Source: <direct assignment or via group>
Next step: <export, generate metadata, or deploy changes>---
Cross-Skill Integration
| Need | Delegate to | Reason |
|---|---|---|
| generate or modify permission metadata | sf-metadata | metadata authoring |
| deploy permission changes | sf-deploy | rollout |
| identify Apex classes needing grants | sf-apex | implementation context |
| bulk user assignment analysis | sf-data | larger data operations |
---
Reference Map
Start here
- references/permission-model.md
- references/soql-reference.md
- references/workflow-examples.md
Specialized analysis
- references/agent-access-guide.md
- references/usage-examples.md
---
Score Guide
| Score | Meaning |
|---|---|
| 90+ | strong permission analysis with clear access sourcing |
| 75–89 | useful audit with minor gaps |
| 60–74 | partial visibility only |
| < 60 | insufficient evidence; expand analysis |
# Claude Code lifecycle hooks for sf-permissions
# These hooks are registered by the installer (tools/install.py)
# and are NOT part of the Agent Skills open specification.
PreToolUse:
- matcher: Bash
hooks:
- type: command
command: "python3 ${SHARED_HOOKS}/scripts/guardrails.py"
timeout: 5000
# Virtual environment
.venv/
venv/
env/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
# Distribution / packaging
dist/
build/
*.egg-info/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS files
.DS_Store
Thumbs.db
# Test / Coverage
.pytest_cache/
.coverage
htmlcov/
# Temporary files
*.tmp
*.log
Credits & Attribution
PSLab - Permission Set Lab
This skill was inspired by PSLab, an open-source Salesforce permission analysis tool created by Oumaima Arbani.
- GitHub: github.com/OumArbani/PSLab
- Author: Oumaima Arbani
- License: MIT
What We Learned from PSLab
PSLab's Apex implementation provided the conceptual foundation for this Python-based skill:
1. Permission Hierarchy Visualization - The tree structure approach for showing PS/PSG relationships 2. Permission Detection Queries - The SOQL patterns for finding "who has access to X" 3. User Permission Analysis - The approach to tracing permissions through PSG membership 4. Setup Entity Access - How to query Apex class, VF page, and Custom Permission access
Why Python Instead of Apex?
While PSLab uses Apex deployed to the user's org, this skill uses Python with simple-salesforce for several reasons:
1. No deployment required - Works with any org via API 2. Cross-org analysis - Can compare permissions across multiple orgs 3. CLI integration - Fits the Claude Code terminal workflow 4. Rich TUI output - Better terminal visualization with the rich library
License Compliance
This skill is a clean-room reimplementation of PSLab's concepts in Python. No code was directly copied. The SOQL query patterns are based on standard Salesforce APIs and are not copyrightable.
---
Other Resources
Salesforce Documentation
Libraries Used
- [simple-salesforce](https://github.com/simple-salesforce/simple-salesforce) - Salesforce API client for Python (Apache 2.0)
- [Rich](https://github.com/Textualize/rich) - Terminal formatting library (MIT)
MIT License
Copyright (c) 2026 Jag Valaiyapathy
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
sf-permissions
Salesforce Permission Set analysis, visualization, and auditing tool.
Quick Start
# 1. Create virtual environment
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# 2. Install dependencies
pip install -r requirements.txt
# 3. Ensure you're authenticated via sf CLI
sf org login web --set-default
# 4. Run commands
python scripts/cli.py hierarchy # View org hierarchy
python scripts/cli.py detect object Account --access delete # Who can delete Accounts?
python scripts/cli.py user john@example.com # Analyze user permissionsFeatures
- Permission Hierarchy Viewer: Visualize PSG → PS relationships
- Permission Detector: "Who has access to X?" for objects, fields, Apex, custom permissions
- User Analyzer: See all permissions assigned to a specific user
- CSV/JSON Export: Export Permission Set configurations
- Dual Output: ASCII trees (terminal) + Mermaid diagrams (documentation)
CLI Commands
# View org permission hierarchy
python scripts/cli.py hierarchy
python scripts/cli.py hierarchy --format mermaid > hierarchy.md
# Detect who has specific permissions
python scripts/cli.py detect object Account --access delete
python scripts/cli.py detect field Account.AnnualRevenue --access edit
python scripts/cli.py detect apex MyApexController
python scripts/cli.py detect custom Can_Approve_Expenses
python scripts/cli.py detect system ModifyAllData
# Analyze user permissions
python scripts/cli.py user john@company.com
python scripts/cli.py user 005xx000001234AAA --format mermaid
# Export Permission Set
python scripts/cli.py export Sales_Manager -o /tmp/sm.csv
python scripts/cli.py export Sales_Manager -o /tmp/sm.json
# View Permission Set details
python scripts/cli.py ps Sales_Manager
python scripts/cli.py psg Sales_Cloud_User
python scripts/cli.py users Sales_Manager # List users with this PSDocumentation
- SKILL.md - Full skill definition and SOQL reference
- references/permission-model.md - How Salesforce permissions work
- references/soql-reference.md - Permission-related SOQL queries
- references/usage-examples.md - CLI and Python API examples
Credits
Inspired by PSLab by Oumaima Arbani.
License
MIT License - see LICENSE
<!-- Parent: sf-permissions/SKILL.md -->
Agent Access Permissions & Visibility Troubleshooting
Agent Access Permissions
Employee Agents require explicit access via the <agentAccesses> element in Permission Sets. Without this, users won't see the agent in the Lightning Experience Copilot panel.
Permission Set XML Structure:
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<agentAccesses>
<agentName>Case_Assist</agentName>
<enabled>true</enabled>
</agentAccesses>
<hasActivationRequired>false</hasActivationRequired>
<label>Case Assist Agent Access</label>
</PermissionSet>Key Points:
<agentName>must exactly match thedeveloper_namein the agent's config block- Multiple
<agentAccesses>elements can be included for multiple agents <enabled>true</enabled>grants access;falseor omission denies access
Deploy and Assign:
# Deploy permission set
sf project deploy start --source-dir force-app/main/default/permissionsets/Agent_Access.permissionset-meta.xml -o TARGET_ORG
# Assign via Setup > Permission Sets > Manage Assignments---
Visibility Troubleshooting
When an Agentforce Employee Agent is deployed but not visible to users:
Step 1: Verify Agent Status
sf org open -p "/lightning/setup/EinsteinAgentforce/home" -o TARGET_ORG
# Agent should show Status: ActiveStep 2: Check for Agent Access Permission
# Retrieve permission sets to check for agentAccesses
sf project retrieve start -m "PermissionSet:*" -o TARGET_ORG
# Search for agentAccesses element
grep -r "agentAccesses" force-app/main/default/permissionsets/Step 3: Create Permission Set (if needed)
Create force-app/main/default/permissionsets/MyAgent_Access.permissionset-meta.xml:
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<agentAccesses>
<agentName>MyAgent</agentName>
<enabled>true</enabled>
</agentAccesses>
<hasActivationRequired>false</hasActivationRequired>
<label>MyAgent Access</label>
</PermissionSet>Common Issues
| Symptom | Cause | Solution |
|---|---|---|
| No Agentforce icon | CopilotSalesforceUser PS not assigned | Assign CopilotSalesforceUser permission set |
| Icon visible, agent not in list | Missing agentAccesses | Add <agentAccesses> to permission set |
| Agent visible, errors on open | Agent not fully published | Check agent logs in Setup |
| "Agent not found" error | Name mismatch | Ensure <agentName> matches developer_name exactly |
<!-- Parent: sf-permissions/SKILL.md -->
Salesforce Permission Model
A guide to understanding how permissions work in Salesforce.
Overview
Salesforce uses a layered permission model:
┌─────────────────────────────────────────────────────┐
│ USER │
├─────────────────────────────────────────────────────┤
│ PROFILE │
│ (Base permissions - one per user) │
├─────────────────────────────────────────────────────┤
│ PERMISSION SET GROUPS │
│ (Collections of Permission Sets) │
├─────────────────────────────────────────────────────┤
│ PERMISSION SETS │
│ (Additive permissions) │
└─────────────────────────────────────────────────────┘Key Concepts
Profiles
- One profile per user (mandatory)
- Defines base-level access
- Can restrict or grant permissions
- Legacy approach - Salesforce recommends minimal profiles + Permission Sets
Permission Sets (PS)
- Additive only - can grant access, cannot revoke
- Multiple PS can be assigned to a user
- Can include:
- Object CRUD permissions
- Field-Level Security (FLS)
- Apex Class access
- Visualforce Page access
- Flow access
- Custom Permissions
- Tab visibility
- System permissions
Permission Set Groups (PSG)
- Container for multiple Permission Sets
- Assign one PSG instead of many individual PS
- Simplifies user provisioning
- Status can be "Active" or "Outdated"
Permission Types
Object Permissions
| Permission | Description |
|---|---|
| Create | Insert new records |
| Read | View records |
| Edit | Update existing records |
| Delete | Remove records |
| View All | Read all records regardless of sharing |
| Modify All | Full access regardless of sharing |
Field-Level Security (FLS)
| Permission | Description |
|---|---|
| Read | View field value |
| Edit | Modify field value |
Note: Edit includes Read access.
Setup Entity Access
Access to programmatic components:
| Entity Type | Examples |
|---|---|
| ApexClass | Controller classes, utility classes |
| ApexPage | Visualforce pages |
| Flow | Screen flows, autolaunched flows |
| CustomPermission | Feature flags, custom access controls |
System Permissions
Organization-wide permissions like:
- ViewSetup
- ModifyAllData
- ViewAllData
- ManageUsers
- ApiEnabled
- RunReports
- ExportReport
Common Permission Patterns
Sales User Pattern
Permission Set Group: Sales_Cloud_User
├── Account_Access (PS)
│ └── Account: CRUD
├── Opportunity_Access (PS)
│ └── Opportunity: CRUD
└── Report_Runner (PS)
└── System: RunReports, ExportReportAPI Integration Pattern
Permission Set: Integration_User
├── System: ApiEnabled
├── Objects: Read on required objects
└── Custom Permission: API_Access_EnabledAdmin Lite Pattern
Permission Set: Admin_Lite
├── System: ViewSetup (NOT ModifyAllData)
├── System: ManageUsers
└── Custom Permission: Can_Manage_UsersBest Practices
1. Minimum Necessary Access
Grant only the permissions users actually need.
2. Use Permission Set Groups
Group related PS into PSGs for easier management:
Sales_Cloud_User(PSG) instead of 5 individual PSService_Cloud_User(PSG) for case management
3. Audit Regularly
Use sf-permissions to:
- Find PS with overly broad access (ModifyAllData)
- Identify unused PS
- Document permission structures
4. Naming Conventions
Permission Set: [Department]_[Capability]_PS
Permission Set Group: [Department]_[Role]_PSG
Examples:
- Sales_Account_Edit_PS
- Sales_Manager_PSG
- HR_Employee_Data_Access_PS5. Document Custom Permissions
Custom Permissions should have clear names:
Can_Approve_ExpensesView_Salary_DataExport_Customer_Data
Related Salesforce Documentation
<!-- Parent: sf-permissions/SKILL.md -->
Permission SOQL Reference
Quick reference for SOQL queries used in sf-permissions.
Permission Set Queries
List All Permission Sets
SELECT Id, Name, Label, Description, IsOwnedByProfile, Type
FROM PermissionSet
WHERE IsOwnedByProfile = false
ORDER BY LabelList Permission Set Groups
SELECT Id, DeveloperName, MasterLabel, Status, Description
FROM PermissionSetGroup
ORDER BY MasterLabelGet PSG Components (PS in a Group)
SELECT
PermissionSetGroupId,
PermissionSetGroup.DeveloperName,
PermissionSetId,
PermissionSet.Name,
PermissionSet.Label
FROM PermissionSetGroupComponentGet User's Permission Set Assignments
SELECT
AssigneeId,
PermissionSetId,
PermissionSet.Name,
PermissionSetGroupId,
PermissionSetGroup.DeveloperName
FROM PermissionSetAssignment
WHERE AssigneeId = '005xx...'
AND PermissionSet.IsOwnedByProfile = falseObject Permission Queries
Get All Object Permissions for a PS
SELECT
SobjectType,
PermissionsCreate,
PermissionsRead,
PermissionsEdit,
PermissionsDelete,
PermissionsViewAllRecords,
PermissionsModifyAllRecords
FROM ObjectPermissions
WHERE ParentId = '0PS...'
ORDER BY SobjectTypeFind PS with Specific Object Access
SELECT
Parent.Name,
Parent.Label,
SobjectType,
PermissionsDelete
FROM ObjectPermissions
WHERE SobjectType = 'Account'
AND PermissionsDelete = trueField Permission Queries
Get Field Permissions for a PS
SELECT Field, PermissionsRead, PermissionsEdit
FROM FieldPermissions
WHERE ParentId = '0PS...'
ORDER BY FieldFind PS with Specific Field Access
SELECT
Parent.Name,
Parent.Label,
Field,
PermissionsRead,
PermissionsEdit
FROM FieldPermissions
WHERE Field = 'Account.AnnualRevenue'
AND PermissionsEdit = trueSetup Entity Access Queries
Get All Setup Entity Access for a PS
SELECT SetupEntityType, SetupEntityId
FROM SetupEntityAccess
WHERE ParentId = '0PS...'Find PS with Apex Class Access
SELECT Parent.Name, Parent.Label
FROM SetupEntityAccess
WHERE SetupEntityType = 'ApexClass'
AND SetupEntityId IN (
SELECT Id FROM ApexClass WHERE Name = 'MyApexClass'
)Find PS with Custom Permission
SELECT Parent.Name, Parent.Label
FROM SetupEntityAccess
WHERE SetupEntityType = 'CustomPermission'
AND SetupEntityId IN (
SELECT Id FROM CustomPermission WHERE DeveloperName = 'Can_Approve'
)Find PS with Visualforce Page Access
SELECT Parent.Name, Parent.Label
FROM SetupEntityAccess
WHERE SetupEntityType = 'ApexPage'
AND SetupEntityId IN (
SELECT Id FROM ApexPage WHERE Name = 'MyVFPage'
)Find PS with Flow Access
SELECT Parent.Name, Parent.Label
FROM SetupEntityAccess
WHERE SetupEntityType = 'Flow'
AND SetupEntityId = '301xx...' -- Active Flow Version IDUser Count Queries
Count Users per Permission Set
SELECT PermissionSetId, COUNT(AssigneeId) userCount
FROM PermissionSetAssignment
GROUP BY PermissionSetIdCount Users per Permission Set Group
SELECT PermissionSetGroupId, COUNT(AssigneeId) userCount
FROM PermissionSetAssignment
WHERE PermissionSetGroupId != null
GROUP BY PermissionSetGroupIdSystem Permission Queries
Find PS with ModifyAllData
SELECT Id, Name, Label
FROM PermissionSet
WHERE PermissionsModifyAllData = true
AND IsOwnedByProfile = falseFind PS with ViewSetup
SELECT Id, Name, Label
FROM PermissionSet
WHERE PermissionsViewSetup = true
AND IsOwnedByProfile = falseMetadata Queries
List All Custom Permissions
SELECT Id, DeveloperName, MasterLabel, Description
FROM CustomPermission
ORDER BY MasterLabelList All Apex Classes
SELECT Id, Name, NamespacePrefix, IsValid
FROM ApexClass
ORDER BY NameList All Flows (with Active Version)
SELECT Id, DeveloperName, MasterLabel, ProcessType, ActiveVersionId
FROM FlowDefinition
WHERE ActiveVersionId != null
ORDER BY MasterLabelEntity Definition Queries
List Customizable Objects
SELECT QualifiedApiName, Label
FROM EntityDefinition
WHERE IsCustomizable = true
ORDER BY LabelGet Fields for an Object
SELECT QualifiedApiName, Label, DataType
FROM FieldDefinition
WHERE EntityDefinition.QualifiedApiName = 'Account'
ORDER BY LabelNotes
- All permission queries are read-only - they don't modify data
ParentIdin ObjectPermissions/FieldPermissions refers to the Permission Set IDSetupEntityIdis the ID of the Apex Class, VF Page, Flow, or Custom Permission- System permissions are fields on the PermissionSet object (e.g.,
PermissionsModifyAllData)
<!-- Parent: sf-permissions/SKILL.md -->
sf-permissions Usage Examples
Real-world examples of using sf-permissions for permission analysis.
CLI Examples
1. View Org Permission Hierarchy
# ASCII tree output (default)
python cli.py hierarchy
# Mermaid diagram for documentation
python cli.py hierarchy --format mermaid > hierarchy.md
# Specify target org
python cli.py hierarchy --target-org my-sandbox2. Permission Detection
Object Permissions
# Who has delete access to Account?
python cli.py detect object Account --access delete
# Who has any access to a custom object?
python cli.py detect object My_Custom_Object__c
# Who has Create, Read, Update access to Opportunity?
python cli.py detect object Opportunity --access create,read,editField Permissions
# Who can edit Account.AnnualRevenue?
python cli.py detect field Account.AnnualRevenue --access edit
# Who has read access to a sensitive field?
python cli.py detect field Contact.SSN__c --access readApex Class Access
# Who has access to a specific Apex class?
python cli.py detect apex MyApexControllerCustom Permissions
# Who has a custom permission?
python cli.py detect custom Can_Approve_ExpensesSystem Permissions
# Who has ModifyAllData (dangerous permission)?
python cli.py detect system ModifyAllData
# Who can view setup?
python cli.py detect system ViewSetup3. User Analysis
# Analyze permissions for a specific user
python cli.py user john.smith@company.com
# Use user ID
python cli.py user 005xx000001234AAA
# Mermaid output
python cli.py user john.smith@company.com --format mermaid4. Export Permission Sets
# Export to CSV
python cli.py export Sales_Manager -o /tmp/sales_manager.csv
# Export to JSON
python cli.py export Sales_Manager -o /tmp/sales_manager.json5. Permission Set Details
# View Permission Set details
python cli.py ps Sales_Manager
# View Permission Set Group details
python cli.py psg Sales_Cloud_User
# List users with a Permission Set
python cli.py users Sales_Manager---
Python API Examples
Connect to Salesforce
from auth import get_sf_connection
# Use default org
sf = get_sf_connection()
# Use specific org
sf = get_sf_connection('my-sandbox')Build Permission Hierarchy
from hierarchy_viewer import get_org_permission_hierarchy
hierarchy = get_org_permission_hierarchy(sf)
print(f"PSGs: {hierarchy.total_psg_count}")
print(f"Total PS: {hierarchy.total_ps_count}")
for psg in hierarchy.permission_set_groups:
print(f" {psg.master_label} ({len(psg.permission_sets)} PS)")Detect Permissions
from permission_detector import (
detect_object_permission,
detect_field_permission,
detect_apex_class_permission,
detect_custom_permission,
)
# Find PS with Account delete
results = detect_object_permission(sf, 'Account', ['delete'])
for r in results:
print(f"{r.permission_set_label}: {r.assigned_user_count} users")
if r.is_in_group:
print(f" In group: {r.group_label}")
# Find PS with field edit access
results = detect_field_permission(sf, 'Account', 'AnnualRevenue', ['edit'])
# Find PS with Apex access
results = detect_apex_class_permission(sf, 'MyController')
# Find PS with custom permission
results = detect_custom_permission(sf, 'Can_Approve_Expenses')Analyze User Permissions
from user_analyzer import analyze_user_permissions, compare_user_permissions
# Analyze single user
analysis = analyze_user_permissions(sf, 'john@company.com')
print(f"User: {analysis.user.name}")
print(f"Profile: {analysis.user.profile_name}")
print(f"Total PS: {analysis.total_permission_sets}")
# Via groups
for group in analysis.via_groups:
print(f"Via {group['label']}:")
for ps in group['permission_sets']:
print(f" - {ps['label']}")
# Direct assignments
for ps in analysis.direct_assignments:
print(f"Direct: {ps.label}")
# Compare two users
comparison = compare_user_permissions(sf, 'user1@company.com', 'user2@company.com')
print(f"Shared: {len(comparison['shared'])} PS")
print(f"User1 only: {len(comparison['ps1_only'])} PS")
print(f"User2 only: {len(comparison['ps2_only'])} PS")Export to Files
from permission_exporter import (
export_permission_set_to_csv,
export_permission_set_to_json,
compare_permission_sets,
)
# Export to CSV
path = export_permission_set_to_csv(sf, 'Sales_Manager', '/tmp/sm.csv')
# Export to JSON
path = export_permission_set_to_json(sf, 'Sales_Manager', '/tmp/sm.json')
# Compare two PS
diff = compare_permission_sets(sf, 'Sales_Manager', 'Sales_Rep')
print(f"Differences: {len(diff['ps1_only'])} in SM only")Render Output
from renderers.ascii_tree import (
render_hierarchy_tree,
render_detection_table,
render_user_tree,
)
from renderers.mermaid import (
render_hierarchy_mermaid,
render_user_mermaid,
)
# ASCII output (Rich library)
render_hierarchy_tree(hierarchy)
render_user_tree(analysis)
render_detection_table(results, "Account delete access")
# Mermaid diagrams
mermaid_code = render_hierarchy_mermaid(hierarchy)
print(mermaid_code) # Paste into markdown---
Common Workflows
Security Audit
# 1. Find all PS with dangerous permissions
dangerous_perms = ['ModifyAllData', 'ViewAllData', 'ManageUsers']
for perm in dangerous_perms:
results = detect_system_permission(sf, perm)
print(f"\n{perm}: {len(results)} PS")
for r in results:
if r.assigned_user_count > 0:
print(f" ⚠️ {r.permission_set_label}: {r.assigned_user_count} users")User Provisioning Check
# Verify a user has expected permissions
expected_ps = ['Sales_Account_Access', 'Report_Runner', 'API_Access']
analysis = analyze_user_permissions(sf, 'new.user@company.com')
user_ps_names = set()
for g in analysis.via_groups:
for ps in g['permission_sets']:
user_ps_names.add(ps['name'])
for ps in analysis.direct_assignments:
user_ps_names.add(ps.name)
missing = set(expected_ps) - user_ps_names
if missing:
print(f"Missing PS: {missing}")
else:
print("All expected PS assigned ✅")Documentation Generation
# Generate permission documentation
hierarchy = get_org_permission_hierarchy(sf)
# Create markdown documentation
doc = "# Org Permission Structure\n\n"
doc += render_hierarchy_mermaid(hierarchy) + "\n\n"
doc += "## Permission Set Groups\n\n"
for psg in hierarchy.permission_set_groups:
doc += f"### {psg.master_label}\n"
doc += f"- Status: {psg.status}\n"
doc += f"- Users: {psg.assigned_user_count}\n"
doc += f"- Permission Sets:\n"
for ps in psg.permission_sets:
doc += f" - {ps.label}\n"
doc += "\n"
with open('/tmp/permissions-doc.md', 'w') as f:
f.write(doc)<!-- Parent: sf-permissions/SKILL.md -->
Common Workflows & Examples
Workflow 1: Audit "Who can delete Accounts?"
User: "Who has delete access to the Account object?"
1. Run permission detector for object:Account with delete access
2. For each PS found, get PSG membership
3. For each PS/PSG, count assigned users
4. Display results in table formatWorkflow 2: Troubleshoot User Access
User: "Why can't John edit Opportunities?"
1. Run user analyzer for john@company.com
2. Check if any PS grants Opportunity edit
3. If not, suggest which PS/PSG to assign
4. Check for conflicting profile restrictionsWorkflow 3: Document Permission Set
User: "Export the Sales_Manager PS for documentation"
1. Run exporter for Sales_Manager
2. Generate CSV with all permissions
3. Optionally generate Mermaid diagram showing PSG membershipExample 1: Full Org Audit
User: "Give me a complete picture of permissions in my org"
Claude:
1. Runs hierarchy viewer to show all PS/PSG
2. Identifies PSGs with "Outdated" status
3. Counts users per PS
4. Generates Mermaid diagram for documentationExample 2: Security Review
User: "Find all PS that grant ModifyAllData"
Claude:
1. Queries PermissionSet for PermissionsModifyAllData = true
2. Lists PS names and assigned user counts
3. Flags any non-admin PS with this powerful permissionExample 3: Permission Set Creation
User: "Create a PS for contractors with read-only Account access"
Claude:
1. Uses permission_generator.py to create XML
2. Sets Account object to Read-only (no Create/Edit/Delete)
3. Outputs .permissionset-meta.xml file# sf-permissions dependencies
# Install with: pip install -r requirements.txt
# Salesforce API client
simple-salesforce>=1.12.0
# Rich TUI library for terminal output
# Provides: trees, tables, panels, progress bars, syntax highlighting
rich>=13.0.0
"""
sf-permissions: Salesforce Permission Set Analysis Tool
A Python-based tool for analyzing, visualizing, and auditing
Salesforce Permission Sets and Permission Set Groups.
Inspired by PSLab (github.com/OumArbani/PSLab)
"""
__version__ = "1.0.0"
__author__ = "Jag Valaiyapathy"
from .auth import get_sf_connection
"""
Authentication module for sf-permissions.
Reuses existing sf CLI authentication to connect to Salesforce orgs.
No additional credentials required - leverages the user's existing
authenticated sessions.
"""
import subprocess
import json
import sys
from typing import Optional
from simple_salesforce import Salesforce
def get_sf_connection(target_org: Optional[str] = None) -> Salesforce:
"""
Get a Salesforce connection using sf CLI authentication.
This function retrieves the access token from an existing sf CLI
authenticated session, avoiding the need to manage credentials separately.
Args:
target_org: Optional org alias or username. If not provided,
uses the default target org from sf CLI.
Returns:
Salesforce: An authenticated simple_salesforce.Salesforce instance.
Raises:
RuntimeError: If sf CLI is not installed or no org is authenticated.
ValueError: If the specified org is not found.
Example:
>>> sf = get_sf_connection() # Use default org
>>> sf = get_sf_connection('my-sandbox') # Use specific org
>>> accounts = sf.query("SELECT Id, Name FROM Account LIMIT 5")
"""
# Build the sf org display command
cmd = ['sf', 'org', 'display', '--json']
if target_org:
cmd.extend(['--target-org', target_org])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
except FileNotFoundError:
raise RuntimeError(
"sf CLI not found. Please install it: https://developer.salesforce.com/tools/salesforcecli"
)
except subprocess.TimeoutExpired:
raise RuntimeError("sf CLI command timed out. Check your network connection.")
if result.returncode != 0:
# Parse error message from sf CLI
try:
error_data = json.loads(result.stdout)
error_msg = error_data.get('message', result.stderr)
except json.JSONDecodeError:
error_msg = result.stderr or result.stdout
if 'No default org' in str(error_msg) or 'no default' in str(error_msg).lower():
raise RuntimeError(
"No default org set. Run 'sf org login web' to authenticate, "
"or specify --target-org."
)
raise RuntimeError(f"sf CLI error: {error_msg}")
# Parse the org info
try:
data = json.loads(result.stdout)
org_info = data.get('result', {})
except json.JSONDecodeError as e:
raise RuntimeError(f"Failed to parse sf CLI output: {e}")
# Extract required fields
instance_url = org_info.get('instanceUrl')
access_token = org_info.get('accessToken')
if not instance_url or not access_token:
raise RuntimeError(
"Could not retrieve org credentials. "
"Your session may have expired. Run 'sf org login web' to re-authenticate."
)
# Create and return the Salesforce connection
return Salesforce(
instance_url=instance_url,
session_id=access_token
)
def list_authenticated_orgs() -> list[dict]:
"""
List all authenticated orgs from sf CLI.
Returns:
List of dicts with org info (alias, username, instanceUrl, isDefaultOrg, etc.)
Example:
>>> orgs = list_authenticated_orgs()
>>> for org in orgs:
... print(f"{org['alias']}: {org['username']}")
"""
cmd = ['sf', 'org', 'list', '--json']
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except FileNotFoundError:
raise RuntimeError("sf CLI not found.")
except subprocess.TimeoutExpired:
raise RuntimeError("sf CLI command timed out.")
if result.returncode != 0:
return []
try:
data = json.loads(result.stdout)
result_data = data.get('result', {})
# Combine scratch orgs, sandboxes, and other orgs
orgs = []
for org_type in ['scratchOrgs', 'sandboxes', 'nonScratchOrgs', 'other']:
orgs.extend(result_data.get(org_type, []))
return orgs
except json.JSONDecodeError:
return []
def get_org_info(target_org: Optional[str] = None) -> dict:
"""
Get detailed information about an org.
Args:
target_org: Optional org alias or username.
Returns:
Dict with org info including username, orgId, instanceUrl, etc.
"""
cmd = ['sf', 'org', 'display', '--json']
if target_org:
cmd.extend(['--target-org', target_org])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(f"Failed to get org info: {result.stderr}")
data = json.loads(result.stdout)
return data.get('result', {})
if __name__ == '__main__':
# Quick test
import sys
target = sys.argv[1] if len(sys.argv) > 1 else None
print("Testing sf CLI authentication...")
try:
sf = get_sf_connection(target)
print(f"✅ Connected to: {sf.sf_instance}")
# Quick API test
identity = sf.query("SELECT Id, Username FROM User WHERE Id = 'me' LIMIT 1")
if identity['records']:
print(f"✅ Authenticated as: {identity['records'][0].get('Username', 'Unknown')}")
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
#!/usr/bin/env python3
"""
sf-permissions CLI
Main command-line interface for the sf-permissions skill.
Provides commands for hierarchy viewing, permission detection,
user analysis, and exporting.
Usage:
python cli.py hierarchy [--target-org ALIAS] [--format ascii|mermaid]
python cli.py detect object Account --access delete
python cli.py detect field Account.AnnualRevenue --access edit
python cli.py detect apex MyApexClass
python cli.py detect custom Can_Approve_Expenses
python cli.py user john.smith@company.com
python cli.py export Sales_Manager --output /tmp/sales_manager.csv
"""
import argparse
import sys
from typing import Optional
# Import our modules
from auth import get_sf_connection
from hierarchy_viewer import get_org_permission_hierarchy, get_permission_set_details, get_psg_details
from permission_detector import (
detect_object_permission,
detect_field_permission,
detect_apex_class_permission,
detect_vf_page_permission,
detect_flow_permission,
detect_custom_permission,
detect_system_permission,
)
from user_analyzer import analyze_user_permissions, get_users_with_permission_set
from permission_exporter import export_permission_set_to_csv, export_permission_set_to_json
from renderers.ascii_tree import (
render_hierarchy_tree,
render_user_tree,
render_detection_table,
render_summary_panel,
)
from renderers.mermaid import render_hierarchy_mermaid, render_user_mermaid
def cmd_hierarchy(args):
"""Display org permission hierarchy."""
sf = get_sf_connection(args.target_org)
print("🔍 Building permission hierarchy...")
hierarchy = get_org_permission_hierarchy(sf)
if args.format == 'mermaid':
output = render_hierarchy_mermaid(hierarchy)
print(output)
else:
render_hierarchy_tree(hierarchy)
def cmd_detect(args):
"""Detect who has access to something."""
sf = get_sf_connection(args.target_org)
perm_type = args.type
name = args.name
# Parse access types if provided
access_types = None
if hasattr(args, 'access') and args.access:
access_types = [a.strip() for a in args.access.split(',')]
print(f"🔍 Detecting {perm_type} permissions for: {name}")
results = []
if perm_type == 'object':
results = detect_object_permission(sf, name, access_types)
elif perm_type == 'field':
# Field name should be in format Object.Field
if '.' in name:
obj, field = name.rsplit('.', 1)
else:
print("❌ Field permission requires format: Object.Field")
return 1
results = detect_field_permission(sf, obj, field, access_types)
elif perm_type == 'apex':
results = detect_apex_class_permission(sf, name)
elif perm_type == 'vf':
results = detect_vf_page_permission(sf, name)
elif perm_type == 'flow':
results = detect_flow_permission(sf, name)
elif perm_type == 'custom':
results = detect_custom_permission(sf, name)
elif perm_type == 'system':
results = detect_system_permission(sf, name)
else:
print(f"❌ Unknown permission type: {perm_type}")
return 1
if not results:
print(f"\n⚠️ No Permission Sets found with {perm_type} access to {name}")
return 0
# Build query description
query_desc = f"{perm_type.title()} access to {name}"
if access_types:
query_desc += f" ({', '.join(access_types)})"
render_detection_table(results, query_desc)
return 0
def cmd_user(args):
"""Analyze user permissions."""
sf = get_sf_connection(args.target_org)
print(f"🔍 Analyzing permissions for: {args.user}")
analysis = analyze_user_permissions(sf, args.user)
if args.format == 'mermaid':
output = render_user_mermaid(analysis)
print(output)
else:
render_user_tree(analysis)
def cmd_export(args):
"""Export Permission Set to file."""
sf = get_sf_connection(args.target_org)
ps_name = args.permission_set
output_path = args.output
# Determine format from extension or argument
if output_path.endswith('.json'):
export_format = 'json'
else:
export_format = 'csv'
print(f"📤 Exporting {ps_name} to {output_path}...")
if export_format == 'json':
result_path = export_permission_set_to_json(sf, ps_name, output_path)
else:
result_path = export_permission_set_to_csv(sf, ps_name, output_path)
print(f"✅ Exported to: {result_path}")
def cmd_ps_details(args):
"""Show details of a Permission Set."""
sf = get_sf_connection(args.target_org)
print(f"🔍 Getting details for Permission Set: {args.name}")
details = get_permission_set_details(sf, args.name)
render_summary_panel(
title=f"Permission Set: {details['info']['label']}",
data={
'Name': details['info']['name'],
'ID': details['info']['id'],
'Description': details['info'].get('description') or '(none)',
'Object Permissions': len(details['object_permissions']),
'Field Permissions': len(details['field_permissions']),
'Setup Entity Access': len(details['setup_entity_access']),
}
)
def cmd_psg_details(args):
"""Show details of a Permission Set Group."""
sf = get_sf_connection(args.target_org)
print(f"🔍 Getting details for Permission Set Group: {args.name}")
details = get_psg_details(sf, args.name)
render_summary_panel(
title=f"Permission Set Group: {details['info']['master_label']}",
data={
'Developer Name': details['info']['developer_name'],
'ID': details['info']['id'],
'Status': details['info']['status'],
'Description': details['info'].get('description') or '(none)',
'Permission Sets': len(details['permission_sets']),
'Assigned Users': len(details['assigned_users']),
}
)
# Show component PS
if details['permission_sets']:
print("\n📋 Component Permission Sets:")
for ps in details['permission_sets']:
print(f" • {ps['label']} ({ps['name']})")
# Show users (first 10)
if details['assigned_users']:
print(f"\n👥 Assigned Users ({len(details['assigned_users'])} shown):")
for user in details['assigned_users'][:10]:
status = "✅" if user['is_active'] else "❌"
print(f" {status} {user['name']} ({user['username']})")
def cmd_users_with_ps(args):
"""List users with a specific Permission Set."""
sf = get_sf_connection(args.target_org)
print(f"🔍 Finding users with Permission Set: {args.permission_set}")
users = get_users_with_permission_set(sf, args.permission_set)
if not users:
print(f"\n⚠️ No users found with Permission Set: {args.permission_set}")
return
print(f"\n👥 Users with {args.permission_set} ({len(users)} total):")
for user in users:
status = "✅" if user.is_active else "❌"
profile = f" [{user.profile_name}]" if user.profile_name else ""
print(f" {status} {user.name} ({user.username}){profile}")
def main():
parser = argparse.ArgumentParser(
description='sf-permissions: Salesforce Permission Set Analysis Tool',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s hierarchy # Show org permission hierarchy
%(prog)s detect object Account --access delete # Who can delete Accounts?
%(prog)s detect apex MyApexClass # Who has access to an Apex class?
%(prog)s user john@company.com # What permissions does John have?
%(prog)s export Sales_Manager -o /tmp/sm.csv # Export PS to CSV
"""
)
parser.add_argument(
'--target-org', '-o',
help='Target org alias or username (default: current default org)'
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Hierarchy command
hierarchy_parser = subparsers.add_parser(
'hierarchy',
help='Display org permission hierarchy'
)
hierarchy_parser.add_argument(
'--format', '-f',
choices=['ascii', 'mermaid'],
default='ascii',
help='Output format (default: ascii)'
)
hierarchy_parser.set_defaults(func=cmd_hierarchy)
# Detect command
detect_parser = subparsers.add_parser(
'detect',
help='Detect who has access to something'
)
detect_parser.add_argument(
'type',
choices=['object', 'field', 'apex', 'vf', 'flow', 'custom', 'system'],
help='Type of permission to detect'
)
detect_parser.add_argument(
'name',
help='Name of object/field/class/permission (for field: Object.Field)'
)
detect_parser.add_argument(
'--access', '-a',
help='Access types to check, comma-separated (e.g., create,read,edit,delete)'
)
detect_parser.set_defaults(func=cmd_detect)
# User command
user_parser = subparsers.add_parser(
'user',
help='Analyze user permissions'
)
user_parser.add_argument(
'user',
help='User ID or username'
)
user_parser.add_argument(
'--format', '-f',
choices=['ascii', 'mermaid'],
default='ascii',
help='Output format (default: ascii)'
)
user_parser.set_defaults(func=cmd_user)
# Export command
export_parser = subparsers.add_parser(
'export',
help='Export Permission Set to file'
)
export_parser.add_argument(
'permission_set',
help='Permission Set name or ID'
)
export_parser.add_argument(
'--output', '-o',
required=True,
help='Output file path (.csv or .json)'
)
export_parser.set_defaults(func=cmd_export)
# PS details command
ps_parser = subparsers.add_parser(
'ps',
help='Show Permission Set details'
)
ps_parser.add_argument(
'name',
help='Permission Set name or ID'
)
ps_parser.set_defaults(func=cmd_ps_details)
# PSG details command
psg_parser = subparsers.add_parser(
'psg',
help='Show Permission Set Group details'
)
psg_parser.add_argument(
'name',
help='Permission Set Group name or ID'
)
psg_parser.set_defaults(func=cmd_psg_details)
# Users with PS command
users_parser = subparsers.add_parser(
'users',
help='List users with a Permission Set'
)
users_parser.add_argument(
'permission_set',
help='Permission Set name or ID'
)
users_parser.set_defaults(func=cmd_users_with_ps)
# Parse and execute
args = parser.parse_args()
if not args.command:
parser.print_help()
return 1
try:
return args.func(args) or 0
except Exception as e:
print(f"❌ Error: {e}")
return 1
if __name__ == '__main__':
sys.exit(main())
"""
Permission Hierarchy Viewer
Builds and displays the complete Permission Set and Permission Set Group
hierarchy for an org. Shows which PS belong to which PSG, and identifies
standalone Permission Sets.
"""
from dataclasses import dataclass, field
from typing import Optional
from simple_salesforce import Salesforce
@dataclass
class PermissionSetInfo:
"""Information about a Permission Set."""
id: str
name: str
label: str
description: Optional[str] = None
is_owned_by_profile: bool = False
is_custom: bool = True
license_name: Optional[str] = None
assigned_user_count: int = 0
@dataclass
class PermissionSetGroupInfo:
"""Information about a Permission Set Group."""
id: str
developer_name: str
master_label: str
description: Optional[str] = None
status: str = "Active" # Active, Outdated, etc.
permission_sets: list[PermissionSetInfo] = field(default_factory=list)
assigned_user_count: int = 0
@dataclass
class OrgPermissionHierarchy:
"""Complete permission hierarchy for an org."""
permission_set_groups: list[PermissionSetGroupInfo]
standalone_permission_sets: list[PermissionSetInfo]
profile_permission_sets: list[PermissionSetInfo]
total_ps_count: int = 0
total_psg_count: int = 0
def get_org_permission_hierarchy(sf: Salesforce) -> OrgPermissionHierarchy:
"""
Build the complete Permission Set and Permission Set Group hierarchy.
This queries:
1. All Permission Set Groups
2. All Permission Sets in each group (via PermissionSetGroupComponent)
3. All standalone Permission Sets (not in any group)
4. Profile-owned Permission Sets (for reference)
Args:
sf: Salesforce connection
Returns:
OrgPermissionHierarchy containing the full structure
Example:
>>> hierarchy = get_org_permission_hierarchy(sf)
>>> print(f"Found {hierarchy.total_psg_count} groups, {hierarchy.total_ps_count} permission sets")
"""
# Step 1: Get all Permission Set Groups
psg_query = """
SELECT Id, DeveloperName, MasterLabel, Description, Status
FROM PermissionSetGroup
ORDER BY MasterLabel
"""
psg_results = sf.query_all(psg_query)
# Step 2: Get all PSG components (PS -> PSG mapping)
component_query = """
SELECT
PermissionSetGroupId,
PermissionSetId,
PermissionSet.Name,
PermissionSet.Label,
PermissionSet.Description
FROM PermissionSetGroupComponent
"""
component_results = sf.query_all(component_query)
# Build a map of PSG ID -> list of PS
psg_to_ps = {}
ps_in_groups = set()
for comp in component_results['records']:
psg_id = comp['PermissionSetGroupId']
if psg_id not in psg_to_ps:
psg_to_ps[psg_id] = []
ps_info = PermissionSetInfo(
id=comp['PermissionSetId'],
name=comp['PermissionSet']['Name'],
label=comp['PermissionSet']['Label'],
description=comp['PermissionSet'].get('Description'),
)
psg_to_ps[psg_id].append(ps_info)
ps_in_groups.add(comp['PermissionSetId'])
# Step 3: Get all Permission Sets (to find standalone ones)
ps_query = """
SELECT Id, Name, Label, Description, IsOwnedByProfile, IsCustom,
License.Name
FROM PermissionSet
WHERE Type != 'Group'
ORDER BY Label
"""
ps_results = sf.query_all(ps_query)
# Step 4: Get user assignment counts for all PS and PSG
ps_counts = _get_ps_user_counts(sf)
psg_counts = _get_psg_user_counts(sf)
# Build the hierarchy
permission_set_groups = []
for psg in psg_results['records']:
psg_id = psg['Id']
ps_list = psg_to_ps.get(psg_id, [])
# Update user counts for PS in this group
for ps in ps_list:
ps.assigned_user_count = ps_counts.get(ps.id, 0)
psg_info = PermissionSetGroupInfo(
id=psg_id,
developer_name=psg['DeveloperName'],
master_label=psg['MasterLabel'],
description=psg.get('Description'),
status=psg.get('Status', 'Active'),
permission_sets=ps_list,
assigned_user_count=psg_counts.get(psg_id, 0),
)
permission_set_groups.append(psg_info)
# Separate standalone and profile-owned PS
standalone_ps = []
profile_ps = []
for ps in ps_results['records']:
ps_id = ps['Id']
# Skip PS that are in groups
if ps_id in ps_in_groups:
continue
license_name = None
if ps.get('License'):
license_name = ps['License'].get('Name')
ps_info = PermissionSetInfo(
id=ps_id,
name=ps['Name'],
label=ps['Label'],
description=ps.get('Description'),
is_owned_by_profile=ps['IsOwnedByProfile'],
is_custom=ps.get('IsCustom', True),
license_name=license_name,
assigned_user_count=ps_counts.get(ps_id, 0),
)
if ps['IsOwnedByProfile']:
profile_ps.append(ps_info)
else:
standalone_ps.append(ps_info)
return OrgPermissionHierarchy(
permission_set_groups=permission_set_groups,
standalone_permission_sets=standalone_ps,
profile_permission_sets=profile_ps,
total_ps_count=len(ps_results['records']),
total_psg_count=len(psg_results['records']),
)
def _get_ps_user_counts(sf: Salesforce) -> dict:
"""Get user assignment counts for all Permission Sets."""
query = """
SELECT PermissionSetId, COUNT(AssigneeId) userCount
FROM PermissionSetAssignment
WHERE PermissionSetId != null
GROUP BY PermissionSetId
"""
results = sf.query_all(query)
return {r['PermissionSetId']: r['userCount'] for r in results['records']}
def _get_psg_user_counts(sf: Salesforce) -> dict:
"""Get user assignment counts for all Permission Set Groups."""
query = """
SELECT PermissionSetGroupId, COUNT(AssigneeId) userCount
FROM PermissionSetAssignment
WHERE PermissionSetGroupId != null
GROUP BY PermissionSetGroupId
"""
results = sf.query_all(query)
return {r['PermissionSetGroupId']: r['userCount'] for r in results['records']}
def get_permission_set_details(sf: Salesforce, ps_name: str) -> dict:
"""
Get detailed information about a specific Permission Set.
Returns object permissions, field permissions, and setup entity access.
Args:
sf: Salesforce connection
ps_name: Name or ID of the Permission Set
Returns:
Dict with 'info', 'object_permissions', 'field_permissions', 'setup_entity_access'
"""
# Get PS info
if ps_name.startswith('0PS'):
ps_query = f"SELECT Id, Name, Label, Description FROM PermissionSet WHERE Id = '{ps_name}'"
else:
ps_query = f"SELECT Id, Name, Label, Description FROM PermissionSet WHERE Name = '{ps_name}'"
ps_result = sf.query(ps_query)
if not ps_result['records']:
raise ValueError(f"Permission Set not found: {ps_name}")
ps = ps_result['records'][0]
ps_id = ps['Id']
# Get object permissions
obj_query = f"""
SELECT SobjectType, PermissionsCreate, PermissionsRead, PermissionsEdit,
PermissionsDelete, PermissionsViewAllRecords, PermissionsModifyAllRecords
FROM ObjectPermissions
WHERE ParentId = '{ps_id}'
ORDER BY SobjectType
"""
obj_results = sf.query_all(obj_query)
# Get field permissions
field_query = f"""
SELECT Field, PermissionsRead, PermissionsEdit
FROM FieldPermissions
WHERE ParentId = '{ps_id}'
ORDER BY Field
"""
field_results = sf.query_all(field_query)
# Get setup entity access (Apex, VF, etc.)
entity_query = f"""
SELECT SetupEntityType, SetupEntityId
FROM SetupEntityAccess
WHERE ParentId = '{ps_id}'
"""
entity_results = sf.query_all(entity_query)
return {
'info': {
'id': ps['Id'],
'name': ps['Name'],
'label': ps['Label'],
'description': ps.get('Description'),
},
'object_permissions': obj_results['records'],
'field_permissions': field_results['records'],
'setup_entity_access': entity_results['records'],
}
def get_psg_details(sf: Salesforce, psg_name: str) -> dict:
"""
Get detailed information about a specific Permission Set Group.
Args:
sf: Salesforce connection
psg_name: DeveloperName or ID of the Permission Set Group
Returns:
Dict with 'info', 'permission_sets', 'assigned_users'
"""
# Get PSG info
if psg_name.startswith('0PG'):
psg_query = f"SELECT Id, DeveloperName, MasterLabel, Description, Status FROM PermissionSetGroup WHERE Id = '{psg_name}'"
else:
psg_query = f"SELECT Id, DeveloperName, MasterLabel, Description, Status FROM PermissionSetGroup WHERE DeveloperName = '{psg_name}'"
psg_result = sf.query(psg_query)
if not psg_result['records']:
raise ValueError(f"Permission Set Group not found: {psg_name}")
psg = psg_result['records'][0]
psg_id = psg['Id']
# Get component PS
comp_query = f"""
SELECT PermissionSetId, PermissionSet.Name, PermissionSet.Label
FROM PermissionSetGroupComponent
WHERE PermissionSetGroupId = '{psg_id}'
"""
comp_results = sf.query_all(comp_query)
# Get assigned users
user_query = f"""
SELECT Assignee.Name, Assignee.Username, Assignee.IsActive
FROM PermissionSetAssignment
WHERE PermissionSetGroupId = '{psg_id}'
LIMIT 100
"""
user_results = sf.query_all(user_query)
return {
'info': {
'id': psg['Id'],
'developer_name': psg['DeveloperName'],
'master_label': psg['MasterLabel'],
'description': psg.get('Description'),
'status': psg.get('Status', 'Active'),
},
'permission_sets': [
{
'id': c['PermissionSetId'],
'name': c['PermissionSet']['Name'],
'label': c['PermissionSet']['Label'],
}
for c in comp_results['records']
],
'assigned_users': [
{
'name': u['Assignee']['Name'],
'username': u['Assignee']['Username'],
'is_active': u['Assignee']['IsActive'],
}
for u in user_results['records']
],
}
if __name__ == '__main__':
from auth import get_sf_connection
sf = get_sf_connection()
print("Building org permission hierarchy...")
hierarchy = get_org_permission_hierarchy(sf)
print(f"\n📊 Summary:")
print(f" Permission Set Groups: {hierarchy.total_psg_count}")
print(f" Total Permission Sets: {hierarchy.total_ps_count}")
print(f" Standalone PS: {len(hierarchy.standalone_permission_sets)}")
print(f"\n📁 Permission Set Groups:")
for psg in hierarchy.permission_set_groups:
status_icon = "✅" if psg.status == "Active" else "⚠️"
print(f" {status_icon} {psg.master_label} ({len(psg.permission_sets)} PS, {psg.assigned_user_count} users)")
for ps in psg.permission_sets[:3]:
print(f" └── {ps.label}")
if len(psg.permission_sets) > 3:
print(f" └── ... and {len(psg.permission_sets) - 3} more")
"""
Metadata Fetcher
Provides helper functions to retrieve available objects, fields,
Apex classes, Visualforce pages, Flows, and Custom Permissions
from a Salesforce org.
Useful for autocomplete, validation, and discovery.
"""
from typing import Optional
from simple_salesforce import Salesforce
def get_available_objects(
sf: Salesforce,
customizable_only: bool = True
) -> list[dict]:
"""
Get all objects available in the org.
Args:
sf: Salesforce connection
customizable_only: If True, only return objects that support FLS/permissions
Returns:
List of dicts with 'api_name', 'label', 'is_custom'
Example:
>>> objects = get_available_objects(sf)
>>> for obj in objects[:5]:
... print(f"{obj['api_name']}: {obj['label']}")
"""
if customizable_only:
query = """
SELECT QualifiedApiName, Label, IsCustomSetting
FROM EntityDefinition
WHERE IsCustomizable = true
ORDER BY Label
"""
else:
query = """
SELECT QualifiedApiName, Label, IsCustomSetting
FROM EntityDefinition
ORDER BY Label
"""
results = sf.query_all(query)
return [
{
'api_name': r['QualifiedApiName'],
'label': r['Label'],
'is_custom': r['QualifiedApiName'].endswith('__c'),
'is_custom_setting': r.get('IsCustomSetting', False),
}
for r in results['records']
]
def get_object_fields(
sf: Salesforce,
object_name: str,
permissionable_only: bool = True
) -> list[dict]:
"""
Get all fields for a specific object.
Args:
sf: Salesforce connection
object_name: API name of the object
permissionable_only: If True, only return fields that can have FLS set
Returns:
List of dicts with 'api_name', 'label', 'data_type', 'is_custom'
Example:
>>> fields = get_object_fields(sf, 'Account')
>>> for f in fields[:5]:
... print(f"{f['api_name']}: {f['data_type']}")
"""
query = f"""
SELECT QualifiedApiName, Label, DataType, IsCompound
FROM FieldDefinition
WHERE EntityDefinition.QualifiedApiName = '{object_name}'
ORDER BY Label
"""
results = sf.query_all(query)
fields = []
for r in results['records']:
# Skip compound fields if only getting permissionable ones
if permissionable_only and r.get('IsCompound', False):
continue
fields.append({
'api_name': r['QualifiedApiName'],
'label': r['Label'],
'data_type': r['DataType'],
'is_custom': r['QualifiedApiName'].endswith('__c'),
})
return fields
def get_apex_classes(sf: Salesforce) -> list[dict]:
"""
Get all Apex classes in the org.
Returns:
List of dicts with 'id', 'name', 'namespace', 'is_valid'
"""
query = """
SELECT Id, Name, NamespacePrefix, IsValid, Status
FROM ApexClass
ORDER BY Name
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['Name'],
'namespace': r.get('NamespacePrefix'),
'is_valid': r.get('IsValid', True),
'status': r.get('Status'),
}
for r in results['records']
]
def get_visualforce_pages(sf: Salesforce) -> list[dict]:
"""
Get all Visualforce pages in the org.
Returns:
List of dicts with 'id', 'name', 'namespace', 'description'
"""
query = """
SELECT Id, Name, NamespacePrefix, Description
FROM ApexPage
ORDER BY Name
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['Name'],
'namespace': r.get('NamespacePrefix'),
'description': r.get('Description'),
}
for r in results['records']
]
def get_flows(sf: Salesforce, active_only: bool = True) -> list[dict]:
"""
Get all Flows in the org.
Args:
sf: Salesforce connection
active_only: If True, only return flows with an active version
Returns:
List of dicts with 'id', 'name', 'label', 'process_type', 'has_active_version'
"""
if active_only:
query = """
SELECT Id, DeveloperName, MasterLabel, ProcessType, ActiveVersionId
FROM FlowDefinition
WHERE ActiveVersionId != null
ORDER BY MasterLabel
"""
else:
query = """
SELECT Id, DeveloperName, MasterLabel, ProcessType, ActiveVersionId
FROM FlowDefinition
ORDER BY MasterLabel
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['DeveloperName'],
'label': r['MasterLabel'],
'process_type': r.get('ProcessType'),
'has_active_version': r.get('ActiveVersionId') is not None,
}
for r in results['records']
]
def get_custom_permissions(sf: Salesforce) -> list[dict]:
"""
Get all Custom Permissions in the org.
Returns:
List of dicts with 'id', 'name', 'label', 'description', 'namespace'
"""
query = """
SELECT Id, DeveloperName, MasterLabel, Description, NamespacePrefix
FROM CustomPermission
ORDER BY MasterLabel
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['DeveloperName'],
'label': r['MasterLabel'],
'description': r.get('Description'),
'namespace': r.get('NamespacePrefix'),
}
for r in results['records']
]
def get_tabs(sf: Salesforce) -> list[dict]:
"""
Get all Tabs in the org (requires describe call).
Returns:
List of dicts with 'name', 'label', 'url'
"""
# Use describe to get tabs
tabs = sf.describe()['tabs'] if hasattr(sf.describe(), '__getitem__') else []
# Alternative: use REST API
try:
response = sf.restful('tabs')
return [
{
'name': t.get('name'),
'label': t.get('label'),
'url': t.get('url'),
}
for t in response
]
except Exception:
# Fallback: query TabDefinition (might not be available in all orgs)
try:
query = "SELECT DurableId, Name, Label FROM TabDefinition ORDER BY Label"
results = sf.query_all(query)
return [
{
'name': r['Name'],
'label': r['Label'],
'durable_id': r['DurableId'],
}
for r in results['records']
]
except Exception:
return []
def get_permission_sets(
sf: Salesforce,
include_profile_owned: bool = False
) -> list[dict]:
"""
Get all Permission Sets in the org.
Args:
sf: Salesforce connection
include_profile_owned: If True, include PS owned by profiles
Returns:
List of dicts with 'id', 'name', 'label', 'is_profile_owned', 'type'
"""
if include_profile_owned:
query = """
SELECT Id, Name, Label, Description, IsOwnedByProfile, Type
FROM PermissionSet
ORDER BY Label
"""
else:
query = """
SELECT Id, Name, Label, Description, IsOwnedByProfile, Type
FROM PermissionSet
WHERE IsOwnedByProfile = false
ORDER BY Label
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['Name'],
'label': r['Label'],
'description': r.get('Description'),
'is_profile_owned': r['IsOwnedByProfile'],
'type': r.get('Type'), # Regular, Session, Group
}
for r in results['records']
]
def get_permission_set_groups(sf: Salesforce) -> list[dict]:
"""
Get all Permission Set Groups in the org.
Returns:
List of dicts with 'id', 'name', 'label', 'status', 'description'
"""
query = """
SELECT Id, DeveloperName, MasterLabel, Status, Description
FROM PermissionSetGroup
ORDER BY MasterLabel
"""
results = sf.query_all(query)
return [
{
'id': r['Id'],
'name': r['DeveloperName'],
'label': r['MasterLabel'],
'status': r.get('Status', 'Active'),
'description': r.get('Description'),
}
for r in results['records']
]
def search_metadata(
sf: Salesforce,
search_term: str,
metadata_types: list[str] = None
) -> dict:
"""
Search across multiple metadata types for a term.
Args:
sf: Salesforce connection
search_term: Term to search for
metadata_types: List of types to search. Options:
'objects', 'fields', 'apex', 'vf', 'flows', 'custom_permissions'
If None, searches all types.
Returns:
Dict with results for each metadata type
"""
if metadata_types is None:
metadata_types = ['objects', 'apex', 'vf', 'flows', 'custom_permissions']
results = {}
search_lower = search_term.lower()
if 'objects' in metadata_types:
objects = get_available_objects(sf)
results['objects'] = [
o for o in objects
if search_lower in o['api_name'].lower() or search_lower in o['label'].lower()
]
if 'apex' in metadata_types:
apex = get_apex_classes(sf)
results['apex_classes'] = [
a for a in apex
if search_lower in a['name'].lower()
]
if 'vf' in metadata_types:
vf = get_visualforce_pages(sf)
results['vf_pages'] = [
v for v in vf
if search_lower in v['name'].lower()
]
if 'flows' in metadata_types:
flows = get_flows(sf, active_only=False)
results['flows'] = [
f for f in flows
if search_lower in f['name'].lower() or search_lower in f['label'].lower()
]
if 'custom_permissions' in metadata_types:
perms = get_custom_permissions(sf)
results['custom_permissions'] = [
p for p in perms
if search_lower in p['name'].lower() or search_lower in p['label'].lower()
]
return results
if __name__ == '__main__':
from auth import get_sf_connection
sf = get_sf_connection()
print("📋 Available Objects (first 10):")
objects = get_available_objects(sf)
for obj in objects[:10]:
custom_tag = " (custom)" if obj['is_custom'] else ""
print(f" • {obj['api_name']}: {obj['label']}{custom_tag}")
print(f"\n📋 Apex Classes: {len(get_apex_classes(sf))}")
print(f"📋 VF Pages: {len(get_visualforce_pages(sf))}")
print(f"📋 Flows: {len(get_flows(sf))}")
print(f"📋 Custom Permissions: {len(get_custom_permissions(sf))}")
"""
Permission Detector - The "Who has access to X?" engine.
This is the crown jewel of sf-permissions. It answers questions like:
- "Who has delete access to Account?"
- "Which Permission Sets grant access to MyApexClass?"
- "Find all PS with Custom_Permission_X"
The detector queries various permission tables and enriches results
with Permission Set Group membership information.
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from simple_salesforce import Salesforce
class PermissionType(Enum):
"""Types of permissions that can be detected."""
OBJECT = "object" # Object CRUD permissions
FIELD = "field" # Field-level security
APEX_CLASS = "apex" # Apex class access
VF_PAGE = "vf" # Visualforce page access
FLOW = "flow" # Flow access
CUSTOM_PERMISSION = "custom" # Custom permissions
TAB = "tab" # Tab visibility
class ObjectAccess(Enum):
"""Object-level access types."""
CREATE = "PermissionsCreate"
READ = "PermissionsRead"
EDIT = "PermissionsEdit"
DELETE = "PermissionsDelete"
VIEW_ALL = "PermissionsViewAllRecords"
MODIFY_ALL = "PermissionsModifyAllRecords"
@dataclass
class DetectionResult:
"""A single permission detection result."""
permission_set_id: str
permission_set_name: str
permission_set_label: str
is_in_group: bool
group_id: Optional[str]
group_name: Optional[str]
group_label: Optional[str]
access_details: dict
assigned_user_count: int = 0
def detect_object_permission(
sf: Salesforce,
object_name: str,
access_types: list[str] = None
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant specific object access.
Args:
sf: Salesforce connection
object_name: API name of the object (e.g., 'Account', 'Custom_Object__c')
access_types: List of access types to check. Options:
'create', 'read', 'edit', 'delete', 'view_all', 'modify_all'
If None, checks for any access.
Returns:
List of DetectionResult with matching Permission Sets
Example:
>>> results = detect_object_permission(sf, 'Account', ['delete'])
>>> for r in results:
... print(f"{r.permission_set_name}: Delete={r.access_details.get('delete')}")
"""
# Build the WHERE clause for access types
if access_types:
access_map = {
'create': 'PermissionsCreate',
'read': 'PermissionsRead',
'edit': 'PermissionsEdit',
'delete': 'PermissionsDelete',
'view_all': 'PermissionsViewAllRecords',
'modify_all': 'PermissionsModifyAllRecords',
}
conditions = [f"{access_map[a.lower()]} = true" for a in access_types if a.lower() in access_map]
access_filter = f"AND ({' OR '.join(conditions)})" if conditions else ""
else:
# Any access
access_filter = """AND (
PermissionsCreate = true OR PermissionsRead = true OR
PermissionsEdit = true OR PermissionsDelete = true OR
PermissionsViewAllRecords = true OR PermissionsModifyAllRecords = true
)"""
query = f"""
SELECT
ParentId, Parent.Name, Parent.Label,
SobjectType,
PermissionsCreate, PermissionsRead, PermissionsEdit, PermissionsDelete,
PermissionsViewAllRecords, PermissionsModifyAllRecords
FROM ObjectPermissions
WHERE SobjectType = '{object_name}'
{access_filter}
"""
results = sf.query_all(query)
if not results['records']:
return []
# Get Permission Set IDs
ps_ids = [r['ParentId'] for r in results['records']]
# Get PSG membership for these Permission Sets
psg_membership = _get_psg_membership(sf, ps_ids)
# Get user counts
user_counts = _get_user_counts(sf, ps_ids)
# Build detection results
detection_results = []
for record in results['records']:
ps_id = record['ParentId']
psg_info = psg_membership.get(ps_id, {})
detection_results.append(DetectionResult(
permission_set_id=ps_id,
permission_set_name=record['Parent']['Name'],
permission_set_label=record['Parent']['Label'],
is_in_group=bool(psg_info),
group_id=psg_info.get('group_id'),
group_name=psg_info.get('group_name'),
group_label=psg_info.get('group_label'),
access_details={
'create': record['PermissionsCreate'],
'read': record['PermissionsRead'],
'edit': record['PermissionsEdit'],
'delete': record['PermissionsDelete'],
'view_all': record['PermissionsViewAllRecords'],
'modify_all': record['PermissionsModifyAllRecords'],
},
assigned_user_count=user_counts.get(ps_id, 0)
))
return detection_results
def detect_field_permission(
sf: Salesforce,
object_name: str,
field_name: str,
access_types: list[str] = None
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant specific field access.
Args:
sf: Salesforce connection
object_name: API name of the object
field_name: API name of the field
access_types: List of access types: 'read', 'edit'. If None, checks for any access.
Returns:
List of DetectionResult with matching Permission Sets
Example:
>>> results = detect_field_permission(sf, 'Account', 'AnnualRevenue', ['edit'])
"""
full_field_name = f"{object_name}.{field_name}"
if access_types:
conditions = []
if 'read' in [a.lower() for a in access_types]:
conditions.append("PermissionsRead = true")
if 'edit' in [a.lower() for a in access_types]:
conditions.append("PermissionsEdit = true")
access_filter = f"AND ({' OR '.join(conditions)})" if conditions else ""
else:
access_filter = "AND (PermissionsRead = true OR PermissionsEdit = true)"
query = f"""
SELECT
ParentId, Parent.Name, Parent.Label,
Field, PermissionsRead, PermissionsEdit
FROM FieldPermissions
WHERE Field = '{full_field_name}'
{access_filter}
"""
results = sf.query_all(query)
if not results['records']:
return []
ps_ids = [r['ParentId'] for r in results['records']]
psg_membership = _get_psg_membership(sf, ps_ids)
user_counts = _get_user_counts(sf, ps_ids)
detection_results = []
for record in results['records']:
ps_id = record['ParentId']
psg_info = psg_membership.get(ps_id, {})
detection_results.append(DetectionResult(
permission_set_id=ps_id,
permission_set_name=record['Parent']['Name'],
permission_set_label=record['Parent']['Label'],
is_in_group=bool(psg_info),
group_id=psg_info.get('group_id'),
group_name=psg_info.get('group_name'),
group_label=psg_info.get('group_label'),
access_details={
'read': record['PermissionsRead'],
'edit': record['PermissionsEdit'],
},
assigned_user_count=user_counts.get(ps_id, 0)
))
return detection_results
def detect_apex_class_permission(
sf: Salesforce,
class_name: str
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant access to an Apex class.
Args:
sf: Salesforce connection
class_name: Name of the Apex class
Returns:
List of DetectionResult with matching Permission Sets
"""
# First, get the Apex class ID
class_query = f"SELECT Id, Name FROM ApexClass WHERE Name = '{class_name}'"
class_result = sf.query(class_query)
if not class_result['records']:
return []
class_id = class_result['records'][0]['Id']
return _detect_setup_entity_access(sf, 'ApexClass', class_id, class_name)
def detect_vf_page_permission(
sf: Salesforce,
page_name: str
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant access to a Visualforce page.
Args:
sf: Salesforce connection
page_name: Name of the Visualforce page
Returns:
List of DetectionResult with matching Permission Sets
"""
# Get the VF page ID
page_query = f"SELECT Id, Name FROM ApexPage WHERE Name = '{page_name}'"
page_result = sf.query(page_query)
if not page_result['records']:
return []
page_id = page_result['records'][0]['Id']
return _detect_setup_entity_access(sf, 'ApexPage', page_id, page_name)
def detect_custom_permission(
sf: Salesforce,
permission_name: str
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant a custom permission.
Args:
sf: Salesforce connection
permission_name: DeveloperName of the custom permission
Returns:
List of DetectionResult with matching Permission Sets
"""
# Get the custom permission ID
perm_query = f"SELECT Id, DeveloperName, MasterLabel FROM CustomPermission WHERE DeveloperName = '{permission_name}'"
perm_result = sf.query(perm_query)
if not perm_result['records']:
return []
perm_id = perm_result['records'][0]['Id']
return _detect_setup_entity_access(sf, 'CustomPermission', perm_id, permission_name)
def detect_flow_permission(
sf: Salesforce,
flow_name: str
) -> list[DetectionResult]:
"""
Find all Permission Sets that grant access to run a Flow.
Args:
sf: Salesforce connection
flow_name: API name of the Flow
Returns:
List of DetectionResult with matching Permission Sets
"""
# Get the Flow ID (use FlowDefinition for the API name)
flow_query = f"""
SELECT Id, DeveloperName, ActiveVersionId
FROM FlowDefinition
WHERE DeveloperName = '{flow_name}'
"""
flow_result = sf.query(flow_query)
if not flow_result['records']:
return []
# SetupEntityAccess uses the active version ID
active_version_id = flow_result['records'][0].get('ActiveVersionId')
if not active_version_id:
return []
return _detect_setup_entity_access(sf, 'Flow', active_version_id, flow_name)
def _detect_setup_entity_access(
sf: Salesforce,
entity_type: str,
entity_id: str,
entity_name: str
) -> list[DetectionResult]:
"""
Generic detection for SetupEntityAccess (Apex, VF, Flow, CustomPermission).
Args:
sf: Salesforce connection
entity_type: Type of entity (ApexClass, ApexPage, Flow, CustomPermission)
entity_id: ID of the entity
entity_name: Name of the entity (for display)
Returns:
List of DetectionResult
"""
query = f"""
SELECT
ParentId, Parent.Name, Parent.Label,
SetupEntityType, SetupEntityId
FROM SetupEntityAccess
WHERE SetupEntityType = '{entity_type}'
AND SetupEntityId = '{entity_id}'
"""
results = sf.query_all(query)
if not results['records']:
return []
ps_ids = [r['ParentId'] for r in results['records']]
psg_membership = _get_psg_membership(sf, ps_ids)
user_counts = _get_user_counts(sf, ps_ids)
detection_results = []
for record in results['records']:
ps_id = record['ParentId']
psg_info = psg_membership.get(ps_id, {})
detection_results.append(DetectionResult(
permission_set_id=ps_id,
permission_set_name=record['Parent']['Name'],
permission_set_label=record['Parent']['Label'],
is_in_group=bool(psg_info),
group_id=psg_info.get('group_id'),
group_name=psg_info.get('group_name'),
group_label=psg_info.get('group_label'),
access_details={
'entity_type': entity_type,
'entity_name': entity_name,
'has_access': True,
},
assigned_user_count=user_counts.get(ps_id, 0)
))
return detection_results
def _get_psg_membership(sf: Salesforce, ps_ids: list[str]) -> dict:
"""
Get Permission Set Group membership for a list of Permission Set IDs.
Returns a dict mapping PS ID -> {group_id, group_name, group_label}
"""
if not ps_ids:
return {}
# Format IDs for IN clause
ids_str = "', '".join(ps_ids)
query = f"""
SELECT
PermissionSetId,
PermissionSetGroupId,
PermissionSetGroup.DeveloperName,
PermissionSetGroup.MasterLabel
FROM PermissionSetGroupComponent
WHERE PermissionSetId IN ('{ids_str}')
"""
results = sf.query_all(query)
membership = {}
for record in results['records']:
ps_id = record['PermissionSetId']
# A PS can be in multiple groups; we just take the first for simplicity
if ps_id not in membership:
membership[ps_id] = {
'group_id': record['PermissionSetGroupId'],
'group_name': record['PermissionSetGroup']['DeveloperName'],
'group_label': record['PermissionSetGroup']['MasterLabel'],
}
return membership
def _get_user_counts(sf: Salesforce, ps_ids: list[str]) -> dict:
"""
Get the count of users assigned to each Permission Set.
Returns a dict mapping PS ID -> user count
"""
if not ps_ids:
return {}
ids_str = "', '".join(ps_ids)
query = f"""
SELECT PermissionSetId, COUNT(AssigneeId) userCount
FROM PermissionSetAssignment
WHERE PermissionSetId IN ('{ids_str}')
GROUP BY PermissionSetId
"""
results = sf.query_all(query)
counts = {}
for record in results['records']:
counts[record['PermissionSetId']] = record['userCount']
return counts
def detect_system_permission(
sf: Salesforce,
permission_name: str
) -> list[DetectionResult]:
"""
Find all Permission Sets that have a specific system permission enabled.
System permissions are fields on the PermissionSet object itself,
like PermissionsModifyAllData, PermissionsViewSetup, etc.
Args:
sf: Salesforce connection
permission_name: Name of the system permission (e.g., 'ModifyAllData', 'ViewSetup')
Returns:
List of DetectionResult with matching Permission Sets
"""
# Ensure the permission name has the Permissions prefix
if not permission_name.startswith('Permissions'):
field_name = f'Permissions{permission_name}'
else:
field_name = permission_name
query = f"""
SELECT Id, Name, Label, {field_name}
FROM PermissionSet
WHERE {field_name} = true
AND IsOwnedByProfile = false
"""
try:
results = sf.query_all(query)
except Exception as e:
if 'INVALID_FIELD' in str(e):
raise ValueError(f"Unknown system permission: {permission_name}")
raise
if not results['records']:
return []
ps_ids = [r['Id'] for r in results['records']]
psg_membership = _get_psg_membership(sf, ps_ids)
user_counts = _get_user_counts(sf, ps_ids)
detection_results = []
for record in results['records']:
ps_id = record['Id']
psg_info = psg_membership.get(ps_id, {})
detection_results.append(DetectionResult(
permission_set_id=ps_id,
permission_set_name=record['Name'],
permission_set_label=record['Label'],
is_in_group=bool(psg_info),
group_id=psg_info.get('group_id'),
group_name=psg_info.get('group_name'),
group_label=psg_info.get('group_label'),
access_details={
'system_permission': permission_name,
'enabled': True,
},
assigned_user_count=user_counts.get(ps_id, 0)
))
return detection_results
# Convenience function for natural language queries
def detect(
sf: Salesforce,
permission_type: str,
name: str,
field: str = None,
access: list[str] = None
) -> list[DetectionResult]:
"""
Unified detection function for natural language processing.
Args:
sf: Salesforce connection
permission_type: One of 'object', 'field', 'apex', 'vf', 'flow', 'custom', 'system'
name: Name of the object/class/page/permission
field: Field name (only for field permissions)
access: Access types to check (only for object/field permissions)
Returns:
List of DetectionResult
Example:
>>> detect(sf, 'object', 'Account', access=['delete'])
>>> detect(sf, 'field', 'Account', field='AnnualRevenue', access=['edit'])
>>> detect(sf, 'apex', 'MyApexClass')
>>> detect(sf, 'custom', 'Can_Approve_Expenses')
"""
ptype = permission_type.lower()
if ptype == 'object':
return detect_object_permission(sf, name, access)
elif ptype == 'field':
if not field:
raise ValueError("Field name required for field permission detection")
return detect_field_permission(sf, name, field, access)
elif ptype == 'apex':
return detect_apex_class_permission(sf, name)
elif ptype == 'vf':
return detect_vf_page_permission(sf, name)
elif ptype == 'flow':
return detect_flow_permission(sf, name)
elif ptype == 'custom':
return detect_custom_permission(sf, name)
elif ptype == 'system':
return detect_system_permission(sf, name)
else:
raise ValueError(f"Unknown permission type: {permission_type}")
if __name__ == '__main__':
# Quick test
import sys
from auth import get_sf_connection
sf = get_sf_connection()
# Test object permission detection
print("Testing: Who has delete access to Account?")
results = detect_object_permission(sf, 'Account', ['delete'])
for r in results:
group_info = f" (in {r.group_name})" if r.is_in_group else " (standalone)"
print(f" • {r.permission_set_name}{group_info} - {r.assigned_user_count} users")
"""
Permission Set Exporter
Exports Permission Set configurations to CSV for documentation,
auditing, and analysis purposes.
"""
import csv
import json
from pathlib import Path
from typing import Optional
from simple_salesforce import Salesforce
def export_permission_set_to_csv(
sf: Salesforce,
ps_name: str,
output_path: str
) -> str:
"""
Export a Permission Set's full configuration to CSV.
Args:
sf: Salesforce connection
ps_name: Permission Set Name or ID
output_path: Path to output CSV file
Returns:
Path to the created CSV file
Example:
>>> path = export_permission_set_to_csv(sf, 'Sales_Manager', '/tmp/sales_manager.csv')
>>> print(f"Exported to: {path}")
"""
# Get PS info
if ps_name.startswith('0PS'):
ps_query = f"SELECT Id, Name, Label FROM PermissionSet WHERE Id = '{ps_name}'"
else:
ps_query = f"SELECT Id, Name, Label FROM PermissionSet WHERE Name = '{ps_name}'"
ps_result = sf.query(ps_query)
if not ps_result['records']:
raise ValueError(f"Permission Set not found: {ps_name}")
ps = ps_result['records'][0]
ps_id = ps['Id']
# Get all permission types
object_perms = _get_object_permissions(sf, ps_id)
field_perms = _get_field_permissions(sf, ps_id)
setup_access = _get_setup_entity_access(sf, ps_id)
system_perms = _get_system_permissions(sf, ps_id)
# Write to CSV
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['Category', 'Name', 'Permission', 'Value', 'Notes'])
# Write header info
writer.writerow(['Info', 'Permission Set Name', '', ps['Name'], ''])
writer.writerow(['Info', 'Permission Set Label', '', ps['Label'], ''])
writer.writerow(['Info', 'Permission Set ID', '', ps['Id'], ''])
writer.writerow([]) # Empty row as separator
# System permissions
for perm in system_perms:
writer.writerow(['System', perm['name'], 'Enabled', 'true', perm.get('description', '')])
if system_perms:
writer.writerow([])
# Object permissions
for op in object_perms:
obj_name = op['SobjectType']
for perm_type, value in [
('Create', op['PermissionsCreate']),
('Read', op['PermissionsRead']),
('Edit', op['PermissionsEdit']),
('Delete', op['PermissionsDelete']),
('ViewAll', op['PermissionsViewAllRecords']),
('ModifyAll', op['PermissionsModifyAllRecords']),
]:
if value:
writer.writerow(['Object', obj_name, perm_type, 'true', ''])
if object_perms:
writer.writerow([])
# Field permissions
for fp in field_perms:
field_name = fp['Field']
if fp['PermissionsRead']:
writer.writerow(['Field', field_name, 'Read', 'true', ''])
if fp['PermissionsEdit']:
writer.writerow(['Field', field_name, 'Edit', 'true', ''])
if field_perms:
writer.writerow([])
# Setup entity access
for sea in setup_access:
entity_type = sea['entity_type']
entity_name = sea['entity_name']
writer.writerow([entity_type, entity_name, 'Access', 'true', ''])
return output_path
def export_permission_set_to_json(
sf: Salesforce,
ps_name: str,
output_path: str
) -> str:
"""
Export a Permission Set's full configuration to JSON.
Args:
sf: Salesforce connection
ps_name: Permission Set Name or ID
output_path: Path to output JSON file
Returns:
Path to the created JSON file
"""
# Get PS info
if ps_name.startswith('0PS'):
ps_query = f"SELECT Id, Name, Label, Description FROM PermissionSet WHERE Id = '{ps_name}'"
else:
ps_query = f"SELECT Id, Name, Label, Description FROM PermissionSet WHERE Name = '{ps_name}'"
ps_result = sf.query(ps_query)
if not ps_result['records']:
raise ValueError(f"Permission Set not found: {ps_name}")
ps = ps_result['records'][0]
ps_id = ps['Id']
# Build the export structure
export_data = {
'permission_set': {
'id': ps['Id'],
'name': ps['Name'],
'label': ps['Label'],
'description': ps.get('Description'),
},
'system_permissions': _get_system_permissions(sf, ps_id),
'object_permissions': _get_object_permissions(sf, ps_id),
'field_permissions': _get_field_permissions(sf, ps_id),
'setup_entity_access': _get_setup_entity_access(sf, ps_id),
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(export_data, f, indent=2)
return output_path
def _get_object_permissions(sf: Salesforce, ps_id: str) -> list[dict]:
"""Get object permissions for a Permission Set."""
query = f"""
SELECT SobjectType, PermissionsCreate, PermissionsRead, PermissionsEdit,
PermissionsDelete, PermissionsViewAllRecords, PermissionsModifyAllRecords
FROM ObjectPermissions
WHERE ParentId = '{ps_id}'
ORDER BY SobjectType
"""
results = sf.query_all(query)
return results['records']
def _get_field_permissions(sf: Salesforce, ps_id: str) -> list[dict]:
"""Get field permissions for a Permission Set."""
query = f"""
SELECT Field, PermissionsRead, PermissionsEdit
FROM FieldPermissions
WHERE ParentId = '{ps_id}'
ORDER BY Field
"""
results = sf.query_all(query)
return results['records']
def _get_setup_entity_access(sf: Salesforce, ps_id: str) -> list[dict]:
"""Get setup entity access (Apex, VF, Flow, Custom Permissions) for a PS."""
query = f"""
SELECT SetupEntityType, SetupEntityId
FROM SetupEntityAccess
WHERE ParentId = '{ps_id}'
"""
results = sf.query_all(query)
# Resolve entity names
entities = []
entity_ids_by_type = {}
for r in results['records']:
entity_type = r['SetupEntityType']
entity_id = r['SetupEntityId']
if entity_type not in entity_ids_by_type:
entity_ids_by_type[entity_type] = []
entity_ids_by_type[entity_type].append(entity_id)
# Resolve names for each type
type_queries = {
'ApexClass': "SELECT Id, Name FROM ApexClass WHERE Id IN ('{ids}')",
'ApexPage': "SELECT Id, Name FROM ApexPage WHERE Id IN ('{ids}')",
'CustomPermission': "SELECT Id, DeveloperName FROM CustomPermission WHERE Id IN ('{ids}')",
}
entity_names = {}
for entity_type, ids in entity_ids_by_type.items():
if entity_type in type_queries and ids:
ids_str = "', '".join(ids)
query = type_queries[entity_type].replace('{ids}', ids_str)
try:
names_result = sf.query_all(query)
for n in names_result['records']:
name_field = 'DeveloperName' if entity_type == 'CustomPermission' else 'Name'
entity_names[n['Id']] = n.get(name_field, n['Id'])
except Exception:
pass
for r in results['records']:
entity_id = r['SetupEntityId']
entities.append({
'entity_type': r['SetupEntityType'],
'entity_id': entity_id,
'entity_name': entity_names.get(entity_id, entity_id),
})
return entities
def _get_system_permissions(sf: Salesforce, ps_id: str) -> list[dict]:
"""Get enabled system permissions for a Permission Set."""
# Query the PS with all permission fields
# Note: This is a subset of common system permissions
system_perm_fields = [
('PermissionsApiEnabled', 'API Enabled'),
('PermissionsViewSetup', 'View Setup'),
('PermissionsModifyAllData', 'Modify All Data'),
('PermissionsViewAllData', 'View All Data'),
('PermissionsManageUsers', 'Manage Users'),
('PermissionsResetPasswords', 'Reset Passwords'),
('PermissionsRunReports', 'Run Reports'),
('PermissionsExportReport', 'Export Reports'),
('PermissionsEditBillingInfo', 'Edit Billing Info'),
('PermissionsManageCategories', 'Manage Categories'),
('PermissionsConvertLeads', 'Convert Leads'),
('PermissionsCreateMultiforce', 'Create Multiforce'),
('PermissionsEditOppLineItemUnitPrice', 'Edit Opportunity Line Item Unit Price'),
('PermissionsEditReadonlyFields', 'Edit Read Only Fields'),
('PermissionsViewAllUsers', 'View All Users'),
('PermissionsAssignTopics', 'Assign Topics'),
('PermissionsAuthorApex', 'Author Apex'),
('PermissionsBulkApiHardDelete', 'Bulk API Hard Delete'),
('PermissionsCanUseNewDashboardBuilder', 'Use New Dashboard Builder'),
('PermissionsChatterInternalUser', 'Chatter Internal User'),
('PermissionsEditMyDashboards', 'Edit My Dashboards'),
('PermissionsEditMyReports', 'Edit My Reports'),
('PermissionsFlowUFLRequired', 'Flow User Feature License Required'),
('PermissionsImportLeads', 'Import Leads'),
('PermissionsInstallPackaging', 'Install Packages'),
('PermissionsLightningConsoleAllowedForUser', 'Lightning Console User'),
('PermissionsManageCustomReportTypes', 'Manage Custom Report Types'),
('PermissionsManageDashboards', 'Manage Dashboards'),
('PermissionsManageReportsInPubFolders', 'Manage Reports in Public Folders'),
('PermissionsMassInlineEdit', 'Mass Inline Edit'),
('PermissionsModifyMetadata', 'Modify Metadata'),
('PermissionsRunFlow', 'Run Flows'),
('PermissionsScheduleReports', 'Schedule Reports'),
('PermissionsSubmitMacrosAllowed', 'Submit Macros'),
('PermissionsTransferAnyEntity', 'Transfer Any Entity'),
('PermissionsTransferAnyLead', 'Transfer Any Lead'),
('PermissionsViewDataCategories', 'View Data Categories'),
('PermissionsViewMyTeamsDashboards', 'View My Teams Dashboards'),
]
field_names = [f[0] for f in system_perm_fields]
field_str = ', '.join(field_names)
query = f"SELECT {field_str} FROM PermissionSet WHERE Id = '{ps_id}'"
try:
result = sf.query(query)
if not result['records']:
return []
ps = result['records'][0]
enabled = []
for field_name, description in system_perm_fields:
if ps.get(field_name, False):
enabled.append({
'name': field_name.replace('Permissions', ''),
'description': description,
})
return enabled
except Exception:
# If query fails (maybe field doesn't exist), return empty
return []
def compare_permission_sets(
sf: Salesforce,
ps1_name: str,
ps2_name: str,
output_path: Optional[str] = None
) -> dict:
"""
Compare two Permission Sets and show differences.
Args:
sf: Salesforce connection
ps1_name: First Permission Set Name or ID
ps2_name: Second Permission Set Name or ID
output_path: Optional path to write comparison CSV
Returns:
Dict with 'ps1_only', 'ps2_only', 'both' permissions
"""
# Get both PS configurations
ps1_data = _get_full_permissions(sf, ps1_name)
ps2_data = _get_full_permissions(sf, ps2_name)
# Compare
ps1_set = set(ps1_data['all_permissions'])
ps2_set = set(ps2_data['all_permissions'])
comparison = {
'ps1': {
'name': ps1_data['name'],
'label': ps1_data['label'],
},
'ps2': {
'name': ps2_data['name'],
'label': ps2_data['label'],
},
'ps1_only': sorted(list(ps1_set - ps2_set)),
'ps2_only': sorted(list(ps2_set - ps1_set)),
'both': sorted(list(ps1_set & ps2_set)),
}
if output_path:
with open(output_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerow(['Permission', ps1_data['name'], ps2_data['name'], 'Status'])
for perm in comparison['both']:
writer.writerow([perm, 'Yes', 'Yes', 'Both'])
for perm in comparison['ps1_only']:
writer.writerow([perm, 'Yes', 'No', f"Only in {ps1_data['name']}"])
for perm in comparison['ps2_only']:
writer.writerow([perm, 'No', 'Yes', f"Only in {ps2_data['name']}"])
return comparison
def _get_full_permissions(sf: Salesforce, ps_name: str) -> dict:
"""Get all permissions as a set of strings for comparison."""
# Get PS info
if ps_name.startswith('0PS'):
ps_query = f"SELECT Id, Name, Label FROM PermissionSet WHERE Id = '{ps_name}'"
else:
ps_query = f"SELECT Id, Name, Label FROM PermissionSet WHERE Name = '{ps_name}'"
ps_result = sf.query(ps_query)
if not ps_result['records']:
raise ValueError(f"Permission Set not found: {ps_name}")
ps = ps_result['records'][0]
ps_id = ps['Id']
all_permissions = []
# Object permissions
for op in _get_object_permissions(sf, ps_id):
obj = op['SobjectType']
if op['PermissionsCreate']:
all_permissions.append(f"Object:{obj}:Create")
if op['PermissionsRead']:
all_permissions.append(f"Object:{obj}:Read")
if op['PermissionsEdit']:
all_permissions.append(f"Object:{obj}:Edit")
if op['PermissionsDelete']:
all_permissions.append(f"Object:{obj}:Delete")
if op['PermissionsViewAllRecords']:
all_permissions.append(f"Object:{obj}:ViewAll")
if op['PermissionsModifyAllRecords']:
all_permissions.append(f"Object:{obj}:ModifyAll")
# Field permissions
for fp in _get_field_permissions(sf, ps_id):
field = fp['Field']
if fp['PermissionsRead']:
all_permissions.append(f"Field:{field}:Read")
if fp['PermissionsEdit']:
all_permissions.append(f"Field:{field}:Edit")
# Setup entity access
for sea in _get_setup_entity_access(sf, ps_id):
all_permissions.append(f"{sea['entity_type']}:{sea['entity_name']}:Access")
# System permissions
for sp in _get_system_permissions(sf, ps_id):
all_permissions.append(f"System:{sp['name']}")
return {
'name': ps['Name'],
'label': ps['Label'],
'all_permissions': all_permissions,
}
if __name__ == '__main__':
import sys
from auth import get_sf_connection
sf = get_sf_connection()
if len(sys.argv) < 2:
print("Usage: python permission_exporter.py <PermissionSetName> [output.csv]")
sys.exit(1)
ps_name = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else f'/tmp/{ps_name}.csv'
print(f"Exporting {ps_name} to {output_path}...")
result_path = export_permission_set_to_csv(sf, ps_name, output_path)
print(f"✅ Exported to: {result_path}")
"""
Renderers for sf-permissions output.
Provides ASCII tree (terminal) and Mermaid (documentation) output formats.
"""
from .ascii_tree import (
render_hierarchy_tree,
render_user_tree,
render_detection_table,
render_summary_panel,
)
from .mermaid import (
render_hierarchy_mermaid,
render_user_mermaid,
)
"""
ASCII Tree Renderer
Provides terminal-friendly output using the Rich library.
Creates beautiful trees, tables, and panels for displaying
permission data in the terminal.
"""
from typing import Optional
try:
from rich.console import Console
from rich.tree import Tree
from rich.table import Table
from rich.panel import Panel
from rich.text import Text
from rich import box
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
# Create console instance
console = Console() if RICH_AVAILABLE else None
def render_hierarchy_tree(hierarchy) -> None:
"""
Render the org permission hierarchy as an ASCII tree.
Args:
hierarchy: OrgPermissionHierarchy object from hierarchy_viewer
"""
if not RICH_AVAILABLE:
_render_hierarchy_fallback(hierarchy)
return
# Create root tree
root = Tree(
"📦 [bold cyan]ORG PERMISSION HIERARCHY[/bold cyan]",
guide_style="dim"
)
# Summary branch
summary = root.add("📊 [bold]Summary[/bold]")
summary.add(f"Permission Set Groups: {hierarchy.total_psg_count}")
summary.add(f"Total Permission Sets: {hierarchy.total_ps_count}")
summary.add(f"Standalone PS: {len(hierarchy.standalone_permission_sets)}")
# PSG branch
psg_branch = root.add(f"📁 [bold]Permission Set Groups[/bold] ({len(hierarchy.permission_set_groups)})")
for psg in hierarchy.permission_set_groups:
status_icon = "✅" if psg.status == "Active" else "⚠️"
users_text = f" ({psg.assigned_user_count} users)" if psg.assigned_user_count else ""
psg_node = psg_branch.add(
f"{status_icon} [cyan]{psg.master_label}[/cyan]{users_text}"
)
for ps in psg.permission_sets[:5]: # Show first 5
psg_node.add(f"[dim]└── {ps.label}[/dim]")
if len(psg.permission_sets) > 5:
psg_node.add(f"[dim]└── ... and {len(psg.permission_sets) - 5} more[/dim]")
# Standalone PS branch
standalone_branch = root.add(
f"📋 [bold]Standalone Permission Sets[/bold] ({len(hierarchy.standalone_permission_sets)})"
)
for ps in hierarchy.standalone_permission_sets[:10]: # Show first 10
users_text = f" ({ps.assigned_user_count} users)" if ps.assigned_user_count else ""
standalone_branch.add(f"[dim]{ps.label}{users_text}[/dim]")
if len(hierarchy.standalone_permission_sets) > 10:
standalone_branch.add(
f"[dim]... and {len(hierarchy.standalone_permission_sets) - 10} more[/dim]"
)
console.print(root)
def _render_hierarchy_fallback(hierarchy) -> None:
"""Fallback rendering without Rich."""
print("\n📦 ORG PERMISSION HIERARCHY")
print("═" * 50)
print(f"\n📊 Summary:")
print(f" Permission Set Groups: {hierarchy.total_psg_count}")
print(f" Total Permission Sets: {hierarchy.total_ps_count}")
print(f" Standalone PS: {len(hierarchy.standalone_permission_sets)}")
print(f"\n📁 Permission Set Groups ({len(hierarchy.permission_set_groups)}):")
for psg in hierarchy.permission_set_groups:
status = "✅" if psg.status == "Active" else "⚠️"
print(f" {status} {psg.master_label}")
for ps in psg.permission_sets[:3]:
print(f" └── {ps.label}")
if len(psg.permission_sets) > 3:
print(f" └── ... and {len(psg.permission_sets) - 3} more")
print(f"\n📋 Standalone Permission Sets ({len(hierarchy.standalone_permission_sets)}):")
for ps in hierarchy.standalone_permission_sets[:10]:
print(f" • {ps.label}")
if len(hierarchy.standalone_permission_sets) > 10:
print(f" ... and {len(hierarchy.standalone_permission_sets) - 10} more")
def render_user_tree(analysis) -> None:
"""
Render user permission analysis as an ASCII tree.
Args:
analysis: UserPermissionAnalysis object from user_analyzer
"""
if not RICH_AVAILABLE:
_render_user_fallback(analysis)
return
user = analysis.user
# Create root tree
status = "[green]Active[/green]" if user.is_active else "[red]Inactive[/red]"
root = Tree(
f"👤 [bold]{user.name}[/bold] ({user.username}) - {status}",
guide_style="dim"
)
# Profile info
info = root.add("ℹ️ [bold]Info[/bold]")
info.add(f"Profile: {user.profile_name or 'N/A'}")
info.add(f"Total Permission Sets: {analysis.total_permission_sets}")
# Via groups
if analysis.via_groups:
groups_branch = root.add(
f"📁 [bold]Via Permission Set Groups[/bold] ({len(analysis.via_groups)})"
)
for group in analysis.via_groups:
group_node = groups_branch.add(f"🔒 [cyan]{group['label']}[/cyan]")
for ps in group['permission_sets'][:5]:
group_node.add(f"[dim]└── {ps['label']}[/dim]")
if len(group['permission_sets']) > 5:
group_node.add(f"[dim]└── ... and {len(group['permission_sets']) - 5} more[/dim]")
# Direct assignments
if analysis.direct_assignments:
direct_branch = root.add(
f"📋 [bold]Direct Permission Sets[/bold] ({len(analysis.direct_assignments)})"
)
for ps in analysis.direct_assignments:
direct_branch.add(f"[dim]{ps.label}[/dim]")
console.print(root)
def _render_user_fallback(analysis) -> None:
"""Fallback rendering without Rich."""
user = analysis.user
print(f"\n👤 {user.name} ({user.username})")
print("═" * 50)
print(f" Profile: {user.profile_name or 'N/A'}")
print(f" Status: {'Active' if user.is_active else 'Inactive'}")
print(f" Total Permission Sets: {analysis.total_permission_sets}")
if analysis.via_groups:
print(f"\n📁 Via Permission Set Groups ({len(analysis.via_groups)}):")
for group in analysis.via_groups:
print(f" 🔒 {group['label']}")
for ps in group['permission_sets'][:3]:
print(f" └── {ps['label']}")
if analysis.direct_assignments:
print(f"\n📋 Direct Permission Sets ({len(analysis.direct_assignments)}):")
for ps in analysis.direct_assignments:
print(f" • {ps.label}")
def render_detection_table(results, query_description: str) -> None:
"""
Render permission detection results as a table.
Args:
results: List of DetectionResult objects
query_description: Human-readable description of the query
"""
if not RICH_AVAILABLE:
_render_detection_fallback(results, query_description)
return
# Create table
table = Table(
title=f"🔍 Permission Detection: {query_description}",
box=box.ROUNDED,
show_header=True,
header_style="bold cyan"
)
table.add_column("Permission Set", style="cyan")
table.add_column("In Group?", justify="center")
table.add_column("Group Name")
table.add_column("Users", justify="right")
table.add_column("Access", style="green")
for r in results:
# Format group info
if r.is_in_group:
in_group = "[green]✓[/green]"
group_name = r.group_label or r.group_name
else:
in_group = "[dim]✗[/dim]"
group_name = "[dim]Standalone[/dim]"
# Format access details
access_str = _format_access_details(r.access_details)
table.add_row(
r.permission_set_label,
in_group,
group_name,
str(r.assigned_user_count),
access_str
)
console.print(table)
# Summary
total_users = sum(r.assigned_user_count for r in results)
console.print(
f"\n📊 Found in [bold]{len(results)}[/bold] Permission Sets, "
f"[bold]{total_users}[/bold] total user assignments"
)
def _format_access_details(access_details: dict) -> str:
"""Format access details for display."""
if not access_details:
return ""
# For object permissions
if 'create' in access_details:
perms = []
if access_details.get('create'):
perms.append('C')
if access_details.get('read'):
perms.append('R')
if access_details.get('edit'):
perms.append('U')
if access_details.get('delete'):
perms.append('D')
if access_details.get('view_all'):
perms.append('+VA')
if access_details.get('modify_all'):
perms.append('+MA')
return ''.join(perms) if perms else 'None'
# For field permissions
if 'read' in access_details and 'edit' in access_details and len(access_details) == 2:
perms = []
if access_details.get('read'):
perms.append('R')
if access_details.get('edit'):
perms.append('W')
return ''.join(perms) if perms else 'None'
# For entity access
if access_details.get('has_access'):
return "✓"
# For system permissions
if access_details.get('enabled'):
return "Enabled"
return str(access_details)
def _render_detection_fallback(results, query_description: str) -> None:
"""Fallback rendering without Rich."""
print(f"\n🔍 Permission Detection: {query_description}")
print("═" * 70)
print(f"{'Permission Set':<25} │ {'In Group?':<12} │ {'Group':<15} │ Users")
print("─" * 70)
for r in results:
in_group = "✓ Yes" if r.is_in_group else "✗ No"
group_name = (r.group_label or r.group_name or "Standalone")[:15]
print(f"{r.permission_set_label[:25]:<25} │ {in_group:<12} │ {group_name:<15} │ {r.assigned_user_count}")
total_users = sum(r.assigned_user_count for r in results)
print(f"\n📊 Found in {len(results)} Permission Sets, {total_users} total user assignments")
def render_summary_panel(title: str, data: dict) -> None:
"""
Render a summary panel with key-value data.
Args:
title: Panel title
data: Dict of key-value pairs to display
"""
if not RICH_AVAILABLE:
_render_summary_fallback(title, data)
return
# Build content
content = ""
for key, value in data.items():
content += f"[bold]{key}:[/bold] {value}\n"
panel = Panel(
content.strip(),
title=f"[bold]{title}[/bold]",
border_style="cyan",
box=box.ROUNDED
)
console.print(panel)
def _render_summary_fallback(title: str, data: dict) -> None:
"""Fallback rendering without Rich."""
print(f"\n╭─ {title} ─╮")
print("│")
for key, value in data.items():
print(f"│ {key}: {value}")
print("│")
print("╰" + "─" * (len(title) + 4) + "╯")
def render_comparison_table(comparison: dict) -> None:
"""
Render a Permission Set comparison table.
Args:
comparison: Dict from compare_permission_sets
"""
if not RICH_AVAILABLE:
_render_comparison_fallback(comparison)
return
ps1_name = comparison['ps1']['name']
ps2_name = comparison['ps2']['name']
table = Table(
title=f"🔄 Comparison: {ps1_name} vs {ps2_name}",
box=box.ROUNDED
)
table.add_column("Category", style="bold")
table.add_column("Count", justify="right")
table.add_row("Shared permissions", str(len(comparison['both'])))
table.add_row(f"Only in {ps1_name}", str(len(comparison['ps1_only'])))
table.add_row(f"Only in {ps2_name}", str(len(comparison['ps2_only'])))
console.print(table)
def _render_comparison_fallback(comparison: dict) -> None:
"""Fallback comparison rendering."""
ps1_name = comparison['ps1']['name']
ps2_name = comparison['ps2']['name']
print(f"\n🔄 Comparison: {ps1_name} vs {ps2_name}")
print("═" * 50)
print(f" Shared permissions: {len(comparison['both'])}")
print(f" Only in {ps1_name}: {len(comparison['ps1_only'])}")
print(f" Only in {ps2_name}: {len(comparison['ps2_only'])}")
"""
Mermaid Diagram Renderer
Generates Mermaid diagram syntax for embedding in Markdown documentation.
Mermaid diagrams can be rendered in GitHub, GitLab, Notion, and many
other documentation platforms.
"""
from typing import Optional
def render_hierarchy_mermaid(hierarchy) -> str:
"""
Render the org permission hierarchy as a Mermaid diagram.
Args:
hierarchy: OrgPermissionHierarchy object
Returns:
Mermaid diagram syntax as a string
Example output:
```mermaid
graph TD
subgraph Permission Set Groups
PSG1[Sales_Cloud_User]
end
```
"""
lines = [
"```mermaid",
"graph TD",
"",
]
# Add PSG subgraph
if hierarchy.permission_set_groups:
lines.append(" subgraph PSGs[Permission Set Groups]")
for i, psg in enumerate(hierarchy.permission_set_groups[:10]): # Limit for readability
status_class = "active" if psg.status == "Active" else "outdated"
lines.append(f" PSG{i}[{_escape_mermaid(psg.master_label)}]:::{status_class}")
if len(hierarchy.permission_set_groups) > 10:
lines.append(f" PSG_more[...and {len(hierarchy.permission_set_groups) - 10} more]")
lines.append(" end")
lines.append("")
# Add PS subgraph
all_ps_in_groups = {}
ps_index = 0
for i, psg in enumerate(hierarchy.permission_set_groups[:10]):
for ps in psg.permission_sets[:5]: # Limit PS per group
ps_key = f"PS{ps_index}"
all_ps_in_groups[ps.id] = ps_key
ps_index += 1
if all_ps_in_groups:
lines.append(" subgraph PSs[Permission Sets in Groups]")
ps_index = 0
for i, psg in enumerate(hierarchy.permission_set_groups[:10]):
for ps in psg.permission_sets[:5]:
lines.append(f" PS{ps_index}[{_escape_mermaid(ps.label)}]")
ps_index += 1
lines.append(" end")
lines.append("")
# Add connections
ps_index = 0
for i, psg in enumerate(hierarchy.permission_set_groups[:10]):
for j, ps in enumerate(psg.permission_sets[:5]):
lines.append(f" PSG{i} --> PS{ps_index}")
ps_index += 1
# Add standalone PS
if hierarchy.standalone_permission_sets:
lines.append("")
lines.append(" subgraph Standalone[Standalone Permission Sets]")
for i, ps in enumerate(hierarchy.standalone_permission_sets[:10]):
lines.append(f" SPS{i}[{_escape_mermaid(ps.label)}]")
if len(hierarchy.standalone_permission_sets) > 10:
lines.append(f" SPS_more[...and {len(hierarchy.standalone_permission_sets) - 10} more]")
lines.append(" end")
# Add styling
lines.extend([
"",
" %% Styling",
" classDef active fill:#90EE90,stroke:#228B22",
" classDef outdated fill:#FFB6C1,stroke:#DC143C",
])
lines.append("```")
return "\n".join(lines)
def render_user_mermaid(analysis) -> str:
"""
Render user permission analysis as a Mermaid diagram.
Args:
analysis: UserPermissionAnalysis object
Returns:
Mermaid diagram syntax as a string
"""
user = analysis.user
lines = [
"```mermaid",
"graph TD",
"",
f" User[👤 {_escape_mermaid(user.name)}]",
"",
]
# Via groups
if analysis.via_groups:
lines.append(" subgraph Groups[Via Permission Set Groups]")
for i, group in enumerate(analysis.via_groups):
lines.append(f" G{i}[🔒 {_escape_mermaid(group['label'])}]")
lines.append(" end")
lines.append("")
# Connect user to groups
for i in range(len(analysis.via_groups)):
lines.append(f" User --> G{i}")
# Add PS in groups
lines.append("")
ps_index = 0
for i, group in enumerate(analysis.via_groups):
for ps in group['permission_sets'][:3]:
lines.append(f" G{i} --> GPS{ps_index}[{_escape_mermaid(ps['label'])}]")
ps_index += 1
# Direct assignments
if analysis.direct_assignments:
lines.append("")
lines.append(" subgraph Direct[Direct Permission Sets]")
for i, ps in enumerate(analysis.direct_assignments[:5]):
lines.append(f" DPS{i}[{_escape_mermaid(ps.label)}]")
if len(analysis.direct_assignments) > 5:
lines.append(f" DPS_more[...and {len(analysis.direct_assignments) - 5} more]")
lines.append(" end")
lines.append("")
# Connect user to direct PS
for i in range(min(5, len(analysis.direct_assignments))):
lines.append(f" User --> DPS{i}")
lines.append("```")
return "\n".join(lines)
def render_detection_mermaid(results, query_description: str) -> str:
"""
Render permission detection results as a Mermaid diagram.
Args:
results: List of DetectionResult objects
query_description: Human-readable description of the query
Returns:
Mermaid diagram syntax as a string
"""
lines = [
"```mermaid",
"graph LR",
"",
f" Query[🔍 {_escape_mermaid(query_description)}]",
"",
]
# Group results by PSG membership
in_groups = [r for r in results if r.is_in_group]
standalone = [r for r in results if not r.is_in_group]
# PSG-assigned PS
if in_groups:
# Group by PSG
psg_map = {}
for r in in_groups:
psg_name = r.group_name or "Unknown"
if psg_name not in psg_map:
psg_map[psg_name] = {
'label': r.group_label or psg_name,
'results': []
}
psg_map[psg_name]['results'].append(r)
lines.append(" subgraph InGroups[In Permission Set Groups]")
for i, (psg_name, psg_data) in enumerate(psg_map.items()):
lines.append(f" PSG{i}[🔒 {_escape_mermaid(psg_data['label'])}]")
lines.append(" end")
lines.append("")
# Connect query to PSGs
for i in range(len(psg_map)):
lines.append(f" Query --> PSG{i}")
# Standalone PS
if standalone:
lines.append("")
lines.append(" subgraph StandalonePS[Standalone Permission Sets]")
for i, r in enumerate(standalone[:5]):
lines.append(f" SPS{i}[{_escape_mermaid(r.permission_set_label)}]")
if len(standalone) > 5:
lines.append(f" SPS_more[...and {len(standalone) - 5} more]")
lines.append(" end")
lines.append("")
# Connect query to standalone PS
for i in range(min(5, len(standalone))):
lines.append(f" Query --> SPS{i}")
lines.append("```")
return "\n".join(lines)
def render_comparison_mermaid(comparison: dict) -> str:
"""
Render Permission Set comparison as a Mermaid diagram.
Args:
comparison: Dict from compare_permission_sets
Returns:
Mermaid diagram syntax as a string
"""
ps1 = comparison['ps1']['name']
ps2 = comparison['ps2']['name']
lines = [
"```mermaid",
"graph TD",
"",
f" PS1[{_escape_mermaid(ps1)}]",
f" PS2[{_escape_mermaid(ps2)}]",
f" Shared[Shared: {len(comparison['both'])} permissions]",
f" PS1Only[Only in {_escape_mermaid(ps1)}: {len(comparison['ps1_only'])}]",
f" PS2Only[Only in {_escape_mermaid(ps2)}: {len(comparison['ps2_only'])}]",
"",
" PS1 --> Shared",
" PS2 --> Shared",
" PS1 --> PS1Only",
" PS2 --> PS2Only",
"",
" style Shared fill:#90EE90",
" style PS1Only fill:#FFB6C1",
" style PS2Only fill:#ADD8E6",
"```"
]
return "\n".join(lines)
def _escape_mermaid(text: str) -> str:
"""
Escape special characters for Mermaid syntax.
Mermaid has issues with certain characters in node labels.
"""
if not text:
return ""
# Replace problematic characters
text = text.replace('"', "'")
text = text.replace('[', '(')
text = text.replace(']', ')')
text = text.replace('{', '(')
text = text.replace('}', ')')
text = text.replace('<', '<')
text = text.replace('>', '>')
text = text.replace('&', '&')
# Truncate if too long
if len(text) > 30:
text = text[:27] + "..."
return text
def generate_flowchart_url(mermaid_code: str) -> str:
"""
Generate a Mermaid Live Editor URL for the diagram.
Args:
mermaid_code: The Mermaid diagram code (without fence markers)
Returns:
URL to the Mermaid Live Editor with the diagram preloaded
Note:
This creates a URL that opens the diagram in mermaid.live
"""
import base64
import json
# Remove code fences if present
code = mermaid_code.strip()
if code.startswith("```mermaid"):
code = code[10:]
if code.endswith("```"):
code = code[:-3]
code = code.strip()
# Create the state object
state = {
"code": code,
"mermaid": {"theme": "default"},
"updateEditor": True
}
# Encode to base64
json_str = json.dumps(state)
encoded = base64.urlsafe_b64encode(json_str.encode()).decode()
return f"https://mermaid.live/edit#base64:{encoded}"