npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

sf-plugin-permission-sets

v0.3.0

Published

Declarative, GitOps-style management of permission set assignments for Salesforce orgs.

Readme

sf-plugin-permission-sets

NPM Downloads/week Stability: experimental License

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: plan then apply, 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 between 0.x releases. Pin a version in CI. The public API stabilizes at v1.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

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 --json on 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-sets

Or 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 sync

Permission 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_Manager

Timed 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:00Z

Expiration 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.yml

Organizing 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.yml
sf 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.yml
sf 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 integrity

sf 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 sync

sf 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 main publishes a snapshot 0.0.0-dev.<run> to the dev dist-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, and plan). Each is a class built from its dependencies and inputs, with a parameterless run() that turns the core into a command's behavior. A service also declares the ports it needs from the outside, like the OrgClient interface 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. ConnectionOrgClient implements the OrgClient port (declared in services) with a Salesforce Connection, 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