sf-plugin-permission-sets
v0.3.0
Published
Declarative, GitOps-style management of permission set assignments for Salesforce orgs.
Maintainers
Readme
sf-plugin-permission-sets
Declarative, GitOps-style management of permission set assignments for Salesforce orgs. Define who gets what in version-controlled YAML. The plugin reconciles your org to match it:
planthenapply, just like Terraform.
⚠️ Under active development. This plugin is
0.x. Per semver's major-version-zero rule, anything (commands, flags, the YAML schema) may change in a breaking way between0.xreleases. Pin a version in CI. The public API stabilizes atv1.0.0.
Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let CI show the diff, and merge to apply. Your git history becomes the audit log of who-had-access-when.
Table of contents
- Why
- Install
- Quick start
- Permission files
- Organizing files
- Modes
- Validations
- Commands
- Inspiration & equivalents
- Versioning
- Architecture
Why
Permission set assignments drift. People get access for a project and keep it forever. Offboarding misses a set. Nobody can answer "who can see X and why?" without a SOQL spelunking session. And in higher environments those grants happen by hand in Setup, with no review and no trail.
This plugin makes the desired state declarative and reviewable:
- ✅ Single source of truth: the YAML in git is authoritative, and the org is reconciled to it.
- ✅ Plan before apply: see exactly what will be added/removed before anything changes.
- ✅ Safe by default: deletions are opt-in and guarded by a delete threshold.
- ✅ CI-native: fully offline
check, exit codes for gating, and--jsonon every command. - ✅ Flexible at the edges: pick your file layout (by permission set or by user) and your sync mode.
- ✅ GitOps for access, the SFDX way: assignments live in source and ship through the same git and CI pipeline as your metadata, instead of being clicked into Setup by hand.
- ✅ Fewer hands in Setup for higher environments: because access is applied from git through CI, fewer people need direct Setup access in UAT and production, and every change is a reviewed pull request with a git audit trail.
Install
sf plugins install sf-plugin-permission-setsOr pin a version:
sf plugins install [email protected]Requires Salesforce CLI (sf) and Node.js 18+.
Quick start
# 1. Bootstrap YAML from an existing org (so you don't start from scratch)
sf ps export --target-org dev --output-file permissions.yml
# 2. Edit the files, commit, open a PR. Validate offline, no org needed:
sf ps check --file "./permissions/*.yml"
# 3. Validate against a real org (do the users/permission sets exist?)
sf ps validate --file "./permissions/*.yml" --target-org dev
# 4. See what would change
sf ps plan --file "./permissions/*.yml" --target-org dev
# 5. Apply it (additive by default, only adds)
sf ps apply --file "./permissions/*.yml" --target-org dev
# 6. Full reconcile, including removals (opt-in)
sf ps apply --file "./permissions/*.yml" --target-org prod --mode syncPermission files
You point every command at one or more YAML files with --file (alias -f).
Multiple files are merged into one model, so splitting by team is encouraged. The files contain only declarative data: knobs like sync mode and exclusions are CLI flags (see Commands), so there's no separate config format to learn yet. Each top-level key is unique within a file, and check flags duplicates.
Each file is a map of usernames, and every scope key under a user is optional (include only what applies):
users:
<username>:
permissionSets:
- <PermissionSet.Name>
permissionSetGroups:
- <PermissionSetGroup.DeveloperName>
permissionSetLicenses:
- <PermissionSetLicense.DeveloperName>A worked example:
users:
[email protected]:
permissionSets:
- Sales_Manager
- Report_Builder
permissionSetGroups:
- Sales_Team_Bundle
permissionSetLicenses:
- SalesforceCRM
[email protected]:
permissionSets:
- Sales_ManagerTimed access (expiration)
A permission set or permission set group entry can be a plain name or an object with an expiration. The expiration is an ISO 8601 datetime, and Salesforce removes access automatically when it passes. Plain names never expire.
users:
[email protected]:
permissionSets:
- Read_Only # permanent
- name: Sales_Manager # expires automatically
expiration: 2026-12-31T23:59:59Z
permissionSetGroups:
- name: Project_Phoenix_Bundle
expiration: 2026-09-30T00:00:00ZExpiration is a property of the grant, so plan and apply treat a changed expiration on an already-assigned target as an update (the ~ line), not an add or a remove. Updates ride with the additive half: they run in additive and sync modes and never count against --max-deletes. Permission set licenses cannot expire (Salesforce has no expiration on PermissionSetLicenseAssign), so the object form is rejected there. export writes the object form for any assignment that currently has an expiration in the org.
The --file flag is repeatable and the plugin expands globs itself, so all of these work:
sf ps plan -o dev --file permissions/sales.yml
sf ps plan -o dev --file "permissions/*.yml" # quote so the plugin (not the shell) expands it
sf ps plan -o dev --file permissions/sales.yml --file permissions/support.ymlOrganizing files
--file takes globs and merges everything it matches, so the folder layout is yours to choose. Two common setups:
Per functional slice. One file per team or domain. Each squad owns its slice, and CODEOWNERS plus PR reviews map to it cleanly. Everything merges into one model.
permissions/
sales.yml
service.yml
marketing.ymlsf ps apply -o prod --file "permissions/*.yml"Per environment. Because usernames differ per org (sandbox suffixes, different integration users), keep a directory per environment and reconcile each against its matching org. Each file is org-specific, which sidesteps username portability entirely.
permissions/
prod/
sales.yml
service.yml
qa/
sales.yml
dev/
sales.ymlsf ps apply -o prod --file "permissions/prod/*.yml"
sf ps apply -o qa --file "permissions/qa/*.yml"The two compose: a directory per environment, each split into functional files.
Modes
A run performs three operations: add missing assignments, update changed expirations on declared ones, and remove undeclared ones. Updates ride with the additive half (they touch a declared grant, never revoke access). The mode selects which it actually executes. Set it with --mode (default additive):
| Mode | Adds missing | Updates expirations | Removes undeclared | Use when… |
| ------------- | :----------: | :-----------------: | :----------------: | --------------------------------------------------------------------- |
| sync | ✅ | ✅ | ✅ | Full reconcile: make the org exactly match the YAML (sync = additive + destructive). |
| additive | ✅ | ✅ | ❌ | Default. Grant access, never revoke. Safe rollout. |
| destructive | ❌ | ❌ | ✅ | Prune/revoke access that isn't declared, without granting anything new. |
plan always shows the full picture (adds, expiration updates, and would-be removes) regardless of mode, so you can preview the impact before running it. Whatever the chosen mode won't act on is surfaced as drift.
Validations
Every run checks the files first. check runs the offline checks with no org, and validate adds the org-side checks. When files merge, most overlaps are unions rather than errors.
| Situation | Checked by | Severity | Result |
| --- | --- | :---: | --- |
| Same user in two files with different targets | check (offline) | ✅ ok | Merged into one model, the point of slicing |
| Same target listed twice for a user | check (offline) | ⚠️ warning | Deduped |
| A user with no scopes, or an empty list | check (offline) | ⚠️ warning | Ignored as a no-op |
| Same username key appears twice in one file | check (offline) | ❌ error | Rejected, the intent is ambiguous |
| Declared user, permission set, group, or license missing or not unique | validate (online) | ❌ error | Run fails before any change |
Commands
| Command | Purpose |
| ---------------- | ---------------------------------------------------------------------- |
| sf ps check | Static analysis of the files alone: schema, duplicates, conflicts, identifier shape. No org, no auth. |
| sf ps validate | Everything check does, plus resolving every user/permission set against the org. |
| sf ps plan | Compute and display the change set: a read-only preview of what apply would do. |
| sf ps apply | Reconcile the org. Honors --mode, prompts before deletes, enforces guardrails. |
| sf ps export | Generate YAML from the current org state to bootstrap adoption. |
sf ps check
Fully offline: runs in any CI job or pre-commit hook without org credentials.
USAGE
$ sf ps check -f <glob>... [--strict] [--json]
FLAGS
-f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs are expanded by the plugin.
--strict Treat warnings as errors.
CHECKS
- valid YAML & schema (unknown keys rejected)
- duplicate assignees / duplicate (user, target) pairs
- conflicting intent across files
- empty or malformed assignee usernames
- internal referential integritysf ps validate
USAGE
$ sf ps validate -o <org> -f <glob>... [--json]
FLAGS
-o, --target-org=<org> (required) Org to resolve against.
-f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
Runs all offline checks, then verifies that every user (active), permission set,
group, and license referenced actually exists and resolves uniquely.sf ps plan
USAGE
$ sf ps plan -o <org> -f <glob>... [--mode <value>] [--json]
FLAGS
-o, --target-org=<org> (required)
-f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
--mode=<value> additive | destructive | sync [default: additive]Example output:
$ sf ps plan -o prod --mode sync
Permission Set Assignments Plan
Org: prod (00D5g0000000abcEAA) Mode: sync
permissionSets:
Sales_Manager
+ [email protected]
~ [email protected] (expires 2026-12-31T23:59:59Z)
- [email protected] (undeclared, will be removed)
= [email protected] (no change)
Report_Builder
+ [email protected]
permissionSetGroups:
Sales_Team_Bundle (no changes)
Plan: 2 to add, 1 to update, 1 to remove, 1 unchanged.
► Review, then run: sf ps apply -o prod --mode syncsf ps apply
USAGE
$ sf ps apply -o <org> -f <glob>... [--mode <value>] [--max-deletes <n>]
[--dry-run] [--no-prompt] [--json]
FLAGS
-o, --target-org=<org> (required)
-f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
--mode=<value> additive | destructive | sync [default: additive]
--max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
--dry-run Resolve and diff, print what would happen, change nothing.
--no-prompt Skip the deletion confirmation prompt (for CI).Deletions always prompt for confirmation unless --no-prompt is set, and are hard-capped by --max-deletes so a bad merge can't unassign your whole org. DML is executed with the sObject Collections API and reports partial successes/failures per record.
sf ps export
Read-only. Snapshots the org's current assignments into a single YAML file you can commit and then feed back into the other commands.
USAGE
$ sf ps export -o <org> --output-file <file> [--json]
FLAGS
-o, --target-org=<org> (required) Org to read assignments from.
--output-file=<file> (required) Path of the YAML file to write. Parent directories are created; an existing file is overwritten.It exports every assignable permission set, group, and license assignment held by active users, keyed by username, so the result is immediately valid input for check, validate, plan, and apply. Profile-owned permission sets and inactive users are skipped.
Inspiration & equivalents
This plugin's command surface borrows ideas from tools you already know:
Versioning
Releases follow semantic versioning. Snapshots are automatic, real releases are a manual decision.
Automatic, no action needed:
- Every push to
mainpublishes a snapshot0.0.0-dev.<run>to thedevdist-tag. - Creating a release triggers CI to build, stamp the version from the tag, publish it with provenance, and smoke-test the result.
Manual, you decide and trigger:
- Choosing the version bump (patch, minor, or major).
- Creating the GitHub Release, which is what triggers the publish above.
While on 0.x: breaking changes may ship in any release, including minor bumps. The plugin is under active development and the public API is not yet stable. The table below describes the contract that takes effect at v1.0.0.
| Bump | When | Example tag |
| --- | --- | --- |
| patch | bug fix, no behavior change | v0.1.1 |
| minor | new backward-compatible feature | v0.2.0 |
| major | breaking change to a command, flag, or the YAML schema | v1.0.0 |
Cut a release with a tag off main:
gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"| dist-tag | Published by | Install |
| --- | --- | --- |
| latest | manual release with a normal tag like v1.2.0 | sf plugins install sf-plugin-permission-sets |
| next | manual release with a hyphenated tag like v1.3.0-beta.1 | sf plugins install sf-plugin-permission-sets@next |
| dev | automatic on every push to main | sf plugins install sf-plugin-permission-sets@dev |
The next tag is selected whenever the version contains a hyphen, not by GitHub's prerelease checkbox.
Architecture
The plugin is layered so every command reuses the same core. Commands stay thin, services hold the orchestration, core holds the reusable primitives, and a thin adapter layer isolates the Salesforce SDK.
- Commands (
src/commands/ps/): oclif only. They parse flags, construct the service (wiring in the org adapter when the command needs one), render output, and set the exit code. - Services (
src/services/): one per command (check,validate,export,apply, andplan). Each is a class built from its dependencies and inputs, with a parameterlessrun()that turns the core into a command's behavior. A service also declares the ports it needs from the outside, like theOrgClientinterface its adapter implements. - Core (
src/core/): the reusable building blocks. Pure, with no@salesforce/*imports, so every piece is unit-testable on its own. - Adapters (
src/adapters/): the boundary to the outside world.ConnectionOrgClientimplements theOrgClientport (declared in services) with a SalesforceConnection, and owns all the SOQL and SObject detail. Services depend on the port, not the SDK, so they test against a fake and stay free of connection detail.
| Core module | Responsibility |
| --- | --- |
| model | Shared domain types (assignment, org, diff). |
| finding | The finding type and code vocabulary, plus constructors, formatting, and counting. |
| schema | The zod contract for a file, plus validation. |
| parse | File text to an object, with YAML and duplicate-key errors. |
| normalize | A validated file to canonical (assignee, kind, target) tuples, plus structural findings. |
| serialize | Canonical tuples back to a user-keyed YAML document (the inverse of normalize). |
| load | Expand globs, run parse then validate then normalize per file, and merge by union. |
| resolve | Pure rules that turn declared references and the org's answers into findings, plus id lookups for assigning. No SOQL: the adapter owns that. |
| diff | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
| report | Format a diff as a plan. |
Commands are slices of one pipeline. check runs the offline load stage only. validate adds resolve: it looks the declared references up through the OrgClient port (the adapter builds the SOQL) and evaluates the org's answers with resolve's pure rules. export runs in the opposite direction: it fetches the org's current assignments through the port and serializes them straight back to YAML, skipping load entirely. apply is the full pipeline: load, resolve to ids, fetch current state, diff, then insert and delete through the Collections API per the mode (guarded by --max-deletes and a confirmation). plan is that same pipeline stopping before the DML: load, resolve to ids, fetch current state, diff, and report, the same preview apply --dry-run produces.
License
BSD-3-Clause © Isaac Ferreira
