Part 4 of 4 in the Salesforce Delivery Team Access series. Part 2 describes the model this metadata implements, and part 3 covers how much of it you should build.
TL;DR
- User permission API names frequently differ from their Setup labels, and the available set varies by edition and enabled features, so a name copied from a blog post will often fail the deploy.
- Generate the authoritative list from the target org: every user permission is a
Permissions<ApiName>boolean field on thePermissionSetobject. Drop thePermissionsprefix to get the metadata<name>. - Deployable XML below for Setup read, API access, metadata deploy, Apex author, session-based production debug, the CI deployment identity, and a role permission set group.
- The CI user holds Author Apex, Customize Application, and Modify Metadata in production, which means this model moves the god account rather than deleting it. Branch protection and pipeline-edit control are part of the access model, not adjacent to it.
- OmniStudio is a capability you add to a role, not a role you clone. Split design-time into an experience layer and a data layer, because Data Mappers and Integration Procedures carry their own field-level security settings.
What You'll Learn
- How to get the correct user permission API names for your specific org, rather than guessing from labels
- Deployable permission set and permission set group XML for the core delivery roles
- How to scope a CI deployment user that can deploy without Modify All Data, and why it is now your highest-value target
- How to handle OmniStudio builders without inventing a role for every combination
- What to query afterwards to confirm the model is actually holding
The Problem
A permission set design is worth nothing if it does not deploy, and permission set metadata has two properties that catch people out.
First, user permission API names are not the labels shown in Setup. Some are close, some are not, and Manage Flow is the classic casualty: a permission set built by transcribing its Setup label will not deploy. Second, the set of permissions available depends on your edition, your licences, and which features are enabled, so a list that works in one org can fail in another.
The result is a deploy that dies with an unknown user permission error, usually at the least convenient moment, and usually in front of the team you were trying to convince.
Common Questions This Article Answers:
- Why won't my permission set deploy? It says the user permission does not exist.
- What permissions does a CI deployment user actually need?
- How should we handle OmniStudio developers and functional consultants?
Quick Answer
Do not copy a permission list from anywhere, including this post. Every user permission exists as a boolean field named Permissions<ApiName> on the PermissionSet object, so describing that object gives you the complete, org-accurate set. Strip the Permissions prefix and you have the value for the <name> element in the XML.
For the CI deployment identity, the pair that replaces Modify All Data is Modify Metadata Through Metadata API Functions plus Customize Application, with Author Apex added because deploying Apex classes requires it. Lock it down at the network and authentication layers rather than the permission layer: one non-human user per environment, login IP ranges restricted to CI runner egress, and JWT bearer authentication through an External Client App.
For OmniStudio, add capability sets to existing roles rather than creating new ones, and split design-time into an experience layer for OmniScripts and FlexCards and a data layer for Data Mappers and Integration Procedures.
Get the permission names from your own org
Every user permission exists as a boolean field named Permissions<ApiName> on the PermissionSet object. Describe it and you have the complete, org-accurate list:
# Every user permission available in this specific org
sf sobject describe --sobject PermissionSet --target-org my-sandbox \
| grep -o '"name" : "Permissions[A-Za-z]*"' \
| sed 's/"name" : "Permissions/ /; s/"//' \
| sort
The rule for turning that into metadata: drop the Permissions prefix. The field PermissionsViewSetup becomes <name>ViewSetup</name> in the XML.
The other reliable method is to read a working example. Retrieve a permission set that already exists and inspect what Salesforce itself wrote:
sf project retrieve start --metadata "PermissionSet:Some_Existing_Set" --target-org my-sandbox
Use one of those two methods for every permission before you write it down, and treat labels in Setup as a hint rather than a name.
The building blocks
Save each of these under force-app/main/default/permissionsets/.
One thing before the XML, because it caught out the first draft of this very post. User permissions have dependencies, and a permission set that omits them will not deploy. ViewSetup requires ViewRoles. ModifyMetadata requires both of those. AuthorApex requires ModifyMetadata. CustomizeApplication requires ManageCustomPermissions. And ViewAllData drags in six more, covered below. Every block here includes its full dependency closure, validated with a check-only deploy against a Summer '26 org. If you change one, re-run the dependency check the same way, because the graph is deep and varies by edition. This is the strongest possible argument for the generate-from-your-org method above: hand-authored permission sets are exactly where this bites.
CAP_Setup_Read.permissionset-meta.xml is the workhorse. Nearly every role holds it in every tier, and on its own it grants no data access at all. ViewSetup needs ViewRoles, so both appear.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>CAP Setup Read</label>
<description>Read-only Setup access. No data access. Safe in every tier including production.</description>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ViewRoles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
</PermissionSet>
CAP_Api_Access.permissionset-meta.xml is split out deliberately rather than bundled, because if your org has API Access Control enabled this permission becomes consequential and you want to see exactly who holds it.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>CAP API Access</label>
<description>API Enabled only. Split from Setup read so API holders are separately auditable.</description>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ApiEnabled</name>
</userPermissions>
</PermissionSet>
CAP_Metadata_Deploy.permissionset-meta.xml is the replacement for Modify All Data that this series argues for. ModifyMetadata pulls in ViewRoles and ViewSetup, and CustomizeApplication pulls in ManageCustomPermissions, so all five appear together.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>CAP Metadata Deploy</label>
<description>Deploy metadata without Modify All Data. Pilot in an integration sandbox first: see the known issue on the permission set path.</description>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ViewRoles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ModifyMetadata</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ManageCustomPermissions</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>CustomizeApplication</name>
</userPermissions>
</PermissionSet>
Pilot this one specifically. There is a long-standing known issue where Modify Metadata granted through a permission set does not take effect for metadata operations invoked from Apex, throwing System.NoAccessException: Not allowed to install or modify metadata via Apex, while the same permission on a profile works. Ordinary sf project deploy start pipelines should be fine, but some managed package post-install scripts take the Apex path.
CAP_Apex_Author.permissionset-meta.xml is where the dependency graph teaches you something about the platform. AuthorApex requires ModifyMetadata, which means you cannot grant the ability to write Apex without also granting the ability to modify metadata. There is no version of this role that authors Apex but cannot deploy declarative change. Treat "can write Apex" and "can change metadata" as the same privilege, because Salesforce does.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>CAP Apex Author</label>
<description>Author Apex. Requires ModifyMetadata, so this necessarily includes metadata modification. T0 and the CI deployment user only.</description>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ViewRoles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ModifyMetadata</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>AuthorApex</name>
</userPermissions>
</PermissionSet>
STEPUP_Prod_Debug.permissionset-meta.xml is the session-based set behind production debugging, and its dependency list is the whole argument made concrete. Debug logs require View All Data, and View All Data cannot be granted alone: it drags in ViewAllForecasts, ViewDataLeakageEvents, ViewEventLogFiles, ViewPlatformEvents, ViewPublicDashboards and ViewPublicReports. So the "give someone debug access" request is not org-wide read of records, it is org-wide read of records plus forecasts plus event logs plus every public report and dashboard. Making that visible in one file is the point.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>STEPUP Prod Debug</label>
<description>Session-activated. Debug logs require View All Data, which pulls in six further view permissions. Org-wide read for one session. Approval and ticket reference required.</description>
<hasActivationRequired>true</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ViewRoles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewAllForecasts</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewDataLeakageEvents</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewEventLogFiles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewPlatformEvents</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewPublicDashboards</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewPublicReports</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewAllData</name>
</userPermissions>
</PermissionSet>
The role group then composes them. This is the production developer role, and its brevity is the entire argument of this series made concrete:
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSetGroup xmlns="http://soap.sforce.com/2006/04/metadata">
<label>ROLE Developer T4</label>
<description>Developer standing access in production: read Setup, retrieve metadata. Nothing else. Elevation via STEPUP_Prod_Debug.</description>
<permissionSets>CAP_Setup_Read</permissionSets>
<permissionSets>CAP_Api_Access</permissionSets>
</PermissionSetGroup>
Deploy the whole set with a standard command, and remember that deploying these grants nobody anything until they are assigned:
sf project deploy start \
--source-dir force-app/main/default/permissionsets \
--source-dir force-app/main/default/permissionsetgroups \
--target-org my-sandbox
The CI user, and the account you just created
CAP_CI_Deploy.permissionset-meta.xml is the deployment identity, and it is the one to scrutinise hardest, because it is the account that replaces the administrator you just removed.
<?xml version="1.0" encoding="UTF-8"?>
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<label>CAP CI Deploy</label>
<description>Pipeline deployment identity. One user per environment, IP-locked, JWT authenticated. No Modify All Data.</description>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ViewRoles</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ViewSetup</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ModifyMetadata</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ManageCustomPermissions</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>CustomizeApplication</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>AuthorApex</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>ApiEnabled</name>
</userPermissions>
</PermissionSet>
All six permission sets above were validated together with a check-only deploy (sf project deploy start --dry-run) against a Summer '26 org, so the dependency lists are complete as shown. That is also the exact command to run before you trust them in your own org, because the available permissions and their dependencies vary by edition and enabled features.
Around that permission set, the identity itself matters more than the permissions do. Use a dedicated, non-human, named user on a profile clone whose login IP ranges are locked to CI runner egress, authenticating by JWT bearer flow through an External Client App rather than a password grant. One CI user per environment, never shared, with the production key in a separate secret scope that only a protected branch can read.
If your integrations still authenticate with a username and password, that pattern is on a clock regardless of this model. See the OAuth username-password flow retirement for the migration path and the LoginHistory query that finds which integrations still use it.
Now the uncomfortable part, and it belongs in any honest version of this series: this model moves the god account, it does not delete it.
The CI user holds Author Apex, Customize Application, and Modify Metadata in production. Author Apex is unavoidable, because deploying Apex classes requires it. Which means anyone who can merge to the protected branch, or edit the pipeline definition, now has effective production write access. For a team already running its own DevOps, that is the actual attack surface.
So branch protection, required reviewers, and control over who can edit pipeline configuration are part of this access model, not adjacent to it. A beautifully scoped set of Salesforce permission sets sitting behind a pipeline that any developer can edit is theatre. The same principle you would apply to a connected app applies here: compare what the identity is granted against what it demonstrably uses, which is the exercise in auditing connected apps for granted versus used scope.
If you run OmniStudio
OmniStudio teams usually reach for a fourth and fifth role: developer with OmniStudio, functional with OmniStudio. Do not build them. That is how you get thirty permission set groups nobody can review. OmniStudio is a capability you add to an existing role, not a role you clone.
More usefully, OmniStudio design-time is not one capability. It is two, with genuinely different risk profiles, and separating them is the single most valuable decision here.
| Capability set | Builds | Why it is scoped this way |
|---|---|---|
CAP_Omni_Experience_Design |
OmniScripts, FlexCards | The presentation layer. The worst outcome is a broken screen. |
CAP_Omni_Data_Design |
Data Mappers, Integration Procedures | The data and integration layer. These components carry their own security settings, including whether field-level security is checked, so whoever can edit them can author a component that reads more than the running user should. |
That maps onto the four combinations without inventing new roles:
- Functional consultant gets
CAP_Omni_Experience_Design. They own the guided flow, the steps, and the FlexCard layout. - Developer gets both, or
CAP_Omni_Data_Designalone when the functional owns the interface. Developers own the integration layer, the remote actions calling Apex, and any custom Lightning Web Component embedded in an OmniScript. - Functional without OmniStudio and developer without OmniStudio simply do not get either set. Their role group is unchanged.
Two things stay out of these groups.
Runtime access is not a delivery permission. End users need the OmniStudio runtime permission set to run an OmniScript. That is a business access decision owned by the application team, and it belongs in the functional permission sets, not in a delivery role. Mixing them is how you end up unable to explain why a developer can run the claims intake script in production.
Some access lives in the component, not the permission set. OmniStudio components can declare a required permission in their own configuration, so the permission set is not the whole story. Check what your components declare before concluding that a role cannot reach something.
On the tier axis, both OmniStudio design capabilities behave exactly like Customize Application: present in T0 and T2, absent in T3 and T4, with changes reaching production through the pipeline like any other metadata.
A naming warning, because this is where OmniStudio bites. The standard OmniStudio and managed-package (Vlocity) flavours ship different permission sets, Winter '23 folded the OmniStudio User license into the Admin license, and DataRaptor is now documented as Omnistudio Data Mapper, backed by the OmniDataTransform standard object. Read the names out of your own org using the describe method above rather than copying them from anywhere.
Verify it from the logs, not the diagram
An access model is a claim about behaviour, and claims about behaviour are testable. Once the model is in place, the questions worth asking monthly are: who activated a step-up group and why, which permission set assignments in staging and production sit outside the approved roster, and which Setup changes in production happened outside a deployment window.
The first and third are answerable from Setup Audit Trail and event logs without Shield, using the baseline described in free Salesforce event monitoring with EventLogFile. The second is a straightforward SOQL query against PermissionSetAssignment that belongs in a scheduled job. If you want the step-up path itself to be enforced rather than merely approved, transaction security policies for report export shows both the mechanism and the known issues that come with it.
The same instinct applies to every identity in the org, not just human ones. The exercise of comparing what an identity was granted against what it demonstrably uses is the one that finds real over-permissioning, whether the identity is a developer, a connected app, or an Agentforce agent user.
This monthly rhythm is the part teams reliably drop, which is worth naming rather than pretending otherwise. Recertification and drift triage are unglamorous, easy to defer, and only conspicuous once they have not happened for a year. Part 3 argues that scheduled recertification gets performed twice and quietly abandoned, and that an alert nobody reads is worse than no alert. So if this is not going to survive contact with your delivery schedule, either automate it properly or give it to somebody who will own it. CloudCounsel runs it as a monthly governance retainer. The queries above are the same ones either way.
Frequently Asked Questions
Q: Why won't my permission set deploy? It says the user permission does not exist.
A: Two common causes. The first is a name that differs from its Setup label, or a permission not available in your edition. Do not transcribe labels: every user permission is a boolean field named Permissions<ApiName> on the PermissionSet object, so sf sobject describe --sobject PermissionSet --target-org my-org gives the authoritative list, and you drop the Permissions prefix for the <name> element. Manage Flow is the usual casualty there. The second, and the one that caught the first draft of this post, is a missing dependency permission. The platform rejects a permission set that enables a permission without its prerequisites, with an error like "Permission ViewSetup depends on permission(s): ViewRoles". The dependency graph is deep (ViewSetup needs ViewRoles, ModifyMetadata needs both, AuthorApex needs ModifyMetadata, ViewAllData needs six others), so a check-only deploy with --dry-run is the fastest way to discover the full closure for your org before you commit to it.
Q: How should we handle OmniStudio builders?
A: As a capability added to an existing role, not as separate roles. Split OmniStudio design-time into two permission sets: an experience layer covering OmniScripts and FlexCards, and a data layer covering Data Mappers and Integration Procedures. Functional consultants get the experience layer, developers get the data layer or both. The split matters because Data Mappers and Integration Procedures carry their own security settings, including whether field-level security is checked, so editing them is a more sensitive capability than laying out a screen. Keep end-user runtime permission sets out of delivery roles entirely, and read the permission set names out of your own org, since standard and managed-package OmniStudio differ.
Q: Can the CI user really deploy everything without Modify All Data?
A: In most orgs, yes, but treat it as a hypothesis to test rather than a fact. Pilot a full production-shaped deploy in an integration sandbox with only Modify Metadata Through Metadata API Functions, Customize Application, Author Apex, and API Enabled. If something fails, record which metadata type failed rather than falling back to Modify All Data wholesale. The known issue with the permission set path for Apex-invoked metadata operations is the most likely cause, and the workaround is a profile-based grant for that one user rather than a broader data permission.
Q: Doesn't the CI user just become the new administrator?
A: Yes, and that is the honest limitation of this design. It holds Author Apex and Modify Metadata in production, so anyone who can merge to the protected branch or edit the pipeline definition has effective production write. The Salesforce permissions are only half the control. Branch protection, required reviewers, and restricting who can edit pipeline configuration are the other half, and a team that skips them has moved the risk rather than reduced it.
Key Takeaways
- Read permission API names from your org, not from a blog: describe the
PermissionSetobject and drop thePermissionsprefix. - Deploying a permission set grants nobody anything: assignment is a separate, non-metadata step, which is what makes this safe to ship.
- The metadata deploy pair replaces Modify All Data: Modify Metadata Through Metadata API Functions plus Customize Application, piloted before you rely on it.
- Name the real price of step-up: production debug access is View All Data for a session, and the permission set should say so.
- The CI user is now your highest-value target: branch protection and pipeline-edit control are part of this access model.
- OmniStudio is a capability, not a role: split experience-layer from data-layer design, because the data layer carries its own field-level security settings.
What's Next?
Recommended Reading:
- Part 1: Why your developers don't need Modify All Data for the diagnosis behind all of this
- Part 3: Sizing the model to your team for whether you should build any of it
- Which of Your Connected Apps Use Less Than They're Granted? for auditing integration identities from your own logs
- Agentforce Agent User Least Privilege for the same exercise applied to agent identities
- Fixing 'Could Not Infer a Metadata Type' in the Salesforce CLI for the deploy error you are most likely to hit first
Action Items:
- Run the describe command against a sandbox and save the permission name list somewhere your team can find it.
- Deploy the permission sets above to an integration sandbox. Nothing changes for anyone until you assign them.
- Pilot the CI deployment user with no Modify All Data on a full production-shaped deploy, and record any metadata type that fails.
- Audit who can edit your pipeline configuration and who can approve a merge to the protected branch. That list is now equivalent to production write access.
- If you run OmniStudio, read your actual permission set names out of Setup and map them onto the experience and data split before building anything.
Resources & References
- PermissionSet object reference - the source of truth for
Permissions*field names - PermissionSet metadata type - Metadata API Developer Guide
- Known issue: Modify Metadata Through Metadata API Functions in a permission set - Salesforce Help
- Omnistudio Data Mapper and Integration Procedure Security Settings - Salesforce Help
- Session-Based Permission Set Groups - Salesforce Help
About This Guide: Part 4 of the Salesforce Delivery Team Access series. Last updated July 2026. Permission API names vary by edition and release, so generate them from your target org rather than copying the examples verbatim.
Tags: #salesforce #security #leastprivilege #omnistudio #devops