← Back to Field Notes

Permission Set Groups: The Pattern That Simplifies Everything

·8 min read

A few years ago I got pulled into a financial services org three days before an APRA-driven access review. The brief was simple on paper: for each user, show who granted what and why. It took the security team the better part of a week, because the answer for most people was a stack of individually assigned permission sets — somewhere between six and thirty of them per user — with no single object that said "this is a sales rep, here is what a sales rep gets." One contractor, Ravi, had 34 permission sets assigned directly. Nobody could tell me what half of them did.

That org had done the hard part. They'd already moved off profiles for object and field access. But they'd stopped one step short, and that last step is the one that makes the whole model liveable. This is about permission set groups, and specifically about muting sets — the feature that turns a pile of permission sets into something you can actually reason about.

The Assignment Sprawl Nobody Audits

Permission sets solve permission drift by letting you compose access instead of cloning it. That's the argument I made when I wrote about moving off profiles, and I still believe it. But composition has a failure mode, and I walked straight into it at that financial services org.

When every user gets their access as a handful of directly assigned permission sets, you've traded one problem for another. Profiles gave you drift. Loose permission sets give you sprawl. There was a permission set called Everything_Temp — created in 2019, according to the audit trail, for a go-live that had long since finished — that roughly half the org still carried. Nobody removed it because nobody was sure what would break.

The tell is always the same. When you ask "what does a service agent get?" and the honest answer is "depends which service agent," you don't have a security model. You have a spreadsheet pretending to be one.

One Group Per Role, Assigned Once

A permission set group bundles several permission sets under a single assignable label. You build a "Sales Rep" group out of your base set, your sales function set, and whatever standard exceptions the role carries. Then you assign the group. One assignment per user, and the group is the documentation.

Here's the opinion I've hardened into a rule: I don't assign individual permission sets to users anymore. Only groups. Even when a role needs exactly one permission set, I wrap it in a group of one. The reason is boring and it's the right kind of boring — the day that role needs a second permission set, and it always does, I add it to the group and every assignee gets it. No hunting through user records, no "did we get everyone," no drift. The group is the seam where change happens, and having exactly one seam is worth the small upfront ceremony.

The metadata is unremarkable, which is the point:

<?xml version="1.0" encoding="UTF-8"?>
<PermissionSetGroup xmlns="http://soap.sforce.com/2006/04/metadata">
    <description>Base access, sales function, and the offshore muting set</description>
    <label>Sales Rep</label>
    <mutingPermissionSets>Sales_Rep_Muting</mutingPermissionSets>
    <permissionSets>Base_Access</permissionSets>
    <permissionSets>Sales_Function</permissionSets>
    <status>Updated</status>
</PermissionSetGroup>

The status element matters more than it looks. Setting it to Updated on deploy tells Salesforce to recalculate the group's aggregate permissions. That recalculation isn't instant — on a large group it can take a minute or two — and during that window the effective access is in flux. I've been caught by this in a smoke test that ran too fast after a deploy. Give it a beat.

Muting Sets Earn Their Keep

Groups by themselves are just convenience. The feature that justifies the whole model is the muting permission set, and it's the part most teams never touch.

A muting set lives inside a group and removes permissions that the group's other sets would otherwise grant. This is what lets you reuse a function set across teams that are almost identical but not quite. At the financial services org, the offshore desk needed the entire Sales Rep group — same objects, same tabs, same apps — except they weren't permitted to see the deal Amount field or delete opportunities. Without muting, that means cloning the sales function set, editing the copy, and now you maintain two things that must stay in lockstep forever. With muting, you add one small set to their group.

The counterintuitive part, and the thing that trips everyone up the first time: in a muting set, the permission you switch on is the one you're taking away. Enabling allowDelete mutes delete. It reads backwards until it clicks.

<?xml version="1.0" encoding="UTF-8"?>
<MutingPermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
    <description>Offshore desk: no edit on Amount, no delete on Opportunity</description>
    <fieldPermissions>
        <editable>true</editable>
        <field>Opportunity.Amount</field>
        <readable>false</readable>
    </fieldPermissions>
    <label>Sales Rep Muting</label>
    <objectPermissions>
        <allowCreate>false</allowCreate>
        <allowDelete>true</allowDelete>
        <allowEdit>false</allowEdit>
        <allowRead>false</allowRead>
        <modifyAllRecords>false</modifyAllRecords>
        <object>Opportunity</object>
        <viewAllRecords>false</viewAllRecords>
    </objectPermissions>
</MutingPermissionSet>

Read the field block carefully. editable is true, so edit on Amount gets muted. readable is false, so read is left alone — the desk still sees the field, they just can't change it. That asymmetry, mute edit but keep read, is exactly the case a muting set is built for and exactly the case that's a nightmare to express by cloning sets.

One thing muting does not do: it doesn't touch your Apex. A muting set strips declarative access, but if a class runs without sharing or queries a field the running user shouldn't see, the platform won't stop it. Muting is the declarative half of the story; the Apex half is still on you to enforce CRUD and FLS in code. I've seen teams assume a muting set locked something down when a controller was cheerfully handing the same data back through a Lightning component.

Groups Travel With Your Source

Permission set groups and muting sets are metadata. They live in your repository, they move through sandboxes, and they deploy like anything else. That sounds obvious until you remember that the old way of managing this — clicking permission set assignments onto users in production — was pure configuration drift with no version history.

Because they deploy with source, they belong in the same pipeline as the rest of your metadata, under the same CI/CD discipline I apply to Apex and Flows. A change to who can delete opportunities goes through a pull request, gets reviewed, and lands in production the same way a code change does. That is a governance win that has nothing to do with security features and everything to do with being able to answer "who changed this and when" without a forensic exercise.

Deploying the group is a normal source deploy:

sf project deploy start \
  --source-dir force-app/main/default/permissionsetgroups \
  --source-dir force-app/main/default/mutingpermissionsets \
  --target-org uat

Nothing exotic. That's the whole appeal.

Auditing What a User Actually Has

The payoff comes back to that access review. Once roles are groups, the question "what does a sales rep get" has one answer, and you can pull assignments with a single query instead of a spreadsheet reconciliation:

SELECT Assignee.Name, PermissionSetGroup.MasterLabel, PermissionSetGroup.Status
FROM PermissionSetAssignment
WHERE PermissionSetGroupId != null
ORDER BY Assignee.Name

When I ran the equivalent of this after the migration, the report was legible to a security analyst who'd never opened Setup. Every user mapped to a named group, and the group's contents were in version control. The muting sets showed exactly where a role deviated from the standard and why, because the deviation had a description and a commit behind it. What used to take a week took an afternoon.

I'll be honest about the messy part, because the story wasn't a clean win. We couldn't fully retire direct assignments. A couple of permission sets required session activation, and Salesforce won't let a session-activation-required set sit inside a group. Those stayed as direct assignments, flagged with a comment, and they're still direct assignments today. It bothered me for a while. It doesn't anymore — two exceptions with a documented reason is a governable state, and thirty-four undocumented ones is not.

When a Group Isn't Worth It

The clean answer is "always use groups." In practice there are two cases where I don't reach for one.

The first is a genuinely singular, temporary elevation. A data steward needs edit on one field for a three-week cleanup. Wrapping that in a group is ceremony for something that should expire. I'll assign a small named permission set directly, put an end date in the description, and take it back off. A group implies a role, and a three-week task is not a role.

The second is over-muting. A muting set is a scalpel, and I've watched teams reach for it as a hammer — group after group with layered muting sets until working out a user's effective access means holding three documents in your head and doing subtraction. If you find yourself muting more than you're granting, the function set underneath is drawn wrong. Split it. Muting is for the small deviation from a sound base, not a way to carve one giant set down to fit everyone. If you want a second pair of eyes before an access review, that's the kind of review I do.

Before permission set groups, that org's answer to "who can do this" was a week of archaeology. After, it was a query and an afternoon. The features didn't change what any user could do — they changed whether anyone could say so.

Liked this?

New essays in your inbox when I publish. No cadence, no spam.