sf-plugin-permission-sets 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/LICENSE.md +28 -0
  2. package/README.md +176 -36
  3. package/lib/adapters/connection-org-client.d.ts +7 -3
  4. package/lib/adapters/connection-org-client.d.ts.map +1 -1
  5. package/lib/adapters/connection-org-client.js +94 -63
  6. package/lib/adapters/connection-org-client.js.map +1 -1
  7. package/lib/adapters/index.d.ts +2 -0
  8. package/lib/adapters/index.d.ts.map +1 -0
  9. package/lib/adapters/index.js +2 -0
  10. package/lib/adapters/index.js.map +1 -0
  11. package/lib/commands/ps/apply.d.ts +29 -1
  12. package/lib/commands/ps/apply.d.ts.map +1 -1
  13. package/lib/commands/ps/apply.js +160 -35
  14. package/lib/commands/ps/apply.js.map +1 -1
  15. package/lib/commands/ps/check.d.ts +3 -1
  16. package/lib/commands/ps/check.d.ts.map +1 -1
  17. package/lib/commands/ps/check.js +15 -6
  18. package/lib/commands/ps/check.js.map +1 -1
  19. package/lib/commands/ps/export.d.ts +11 -2
  20. package/lib/commands/ps/export.d.ts.map +1 -1
  21. package/lib/commands/ps/export.js +53 -7
  22. package/lib/commands/ps/export.js.map +1 -1
  23. package/lib/commands/ps/plan.d.ts +51 -6
  24. package/lib/commands/ps/plan.d.ts.map +1 -1
  25. package/lib/commands/ps/plan.js +203 -34
  26. package/lib/commands/ps/plan.js.map +1 -1
  27. package/lib/commands/ps/validate.d.ts +3 -1
  28. package/lib/commands/ps/validate.d.ts.map +1 -1
  29. package/lib/commands/ps/validate.js +16 -7
  30. package/lib/commands/ps/validate.js.map +1 -1
  31. package/lib/core/diff.d.ts.map +1 -1
  32. package/lib/core/diff.js +1 -0
  33. package/lib/core/diff.js.map +1 -1
  34. package/lib/core/finding.d.ts +3 -2
  35. package/lib/core/finding.d.ts.map +1 -1
  36. package/lib/core/finding.js +1 -1
  37. package/lib/core/finding.js.map +1 -1
  38. package/lib/core/index.d.ts +11 -0
  39. package/lib/core/index.d.ts.map +1 -0
  40. package/lib/core/index.js +10 -0
  41. package/lib/core/index.js.map +1 -0
  42. package/lib/core/load.d.ts.map +1 -1
  43. package/lib/core/load.js +17 -10
  44. package/lib/core/load.js.map +1 -1
  45. package/lib/core/mode.d.ts +18 -0
  46. package/lib/core/mode.d.ts.map +1 -0
  47. package/lib/core/mode.js +16 -0
  48. package/lib/core/mode.js.map +1 -0
  49. package/lib/core/model.d.ts +9 -0
  50. package/lib/core/model.d.ts.map +1 -1
  51. package/lib/core/normalize.d.ts +2 -0
  52. package/lib/core/normalize.d.ts.map +1 -1
  53. package/lib/core/normalize.js +7 -0
  54. package/lib/core/normalize.js.map +1 -1
  55. package/lib/core/plan-file.d.ts +61 -0
  56. package/lib/core/plan-file.d.ts.map +1 -0
  57. package/lib/core/plan-file.js +74 -0
  58. package/lib/core/plan-file.js.map +1 -0
  59. package/lib/core/report.d.ts +12 -5
  60. package/lib/core/report.d.ts.map +1 -1
  61. package/lib/core/report.js +84 -32
  62. package/lib/core/report.js.map +1 -1
  63. package/lib/core/schema.d.ts +2 -12
  64. package/lib/core/schema.d.ts.map +1 -1
  65. package/lib/core/schema.js +2 -2
  66. package/lib/core/schema.js.map +1 -1
  67. package/lib/services/adapters/index.d.ts +2 -0
  68. package/lib/services/adapters/index.d.ts.map +1 -0
  69. package/lib/services/adapters/index.js +2 -0
  70. package/lib/services/adapters/index.js.map +1 -0
  71. package/lib/services/adapters/org-client.d.ts +3 -3
  72. package/lib/services/adapters/org-client.d.ts.map +1 -1
  73. package/lib/services/apply.d.ts +21 -11
  74. package/lib/services/apply.d.ts.map +1 -1
  75. package/lib/services/apply.js +54 -37
  76. package/lib/services/apply.js.map +1 -1
  77. package/lib/services/check.d.ts +3 -7
  78. package/lib/services/check.d.ts.map +1 -1
  79. package/lib/services/check.js +5 -12
  80. package/lib/services/check.js.map +1 -1
  81. package/lib/services/export.d.ts +11 -6
  82. package/lib/services/export.d.ts.map +1 -1
  83. package/lib/services/export.js +14 -10
  84. package/lib/services/export.js.map +1 -1
  85. package/lib/services/index.d.ts +7 -0
  86. package/lib/services/index.d.ts.map +1 -0
  87. package/lib/services/index.js +7 -0
  88. package/lib/services/index.js.map +1 -0
  89. package/lib/services/plan.d.ts +7 -10
  90. package/lib/services/plan.d.ts.map +1 -1
  91. package/lib/services/plan.js +28 -18
  92. package/lib/services/plan.js.map +1 -1
  93. package/lib/services/resolution.d.ts +6 -6
  94. package/lib/services/resolution.d.ts.map +1 -1
  95. package/lib/services/resolution.js +21 -9
  96. package/lib/services/resolution.js.map +1 -1
  97. package/lib/services/validate.d.ts +7 -7
  98. package/lib/services/validate.d.ts.map +1 -1
  99. package/lib/services/validate.js +20 -13
  100. package/lib/services/validate.js.map +1 -1
  101. package/messages/ps.apply.md +38 -2
  102. package/messages/ps.check.md +1 -1
  103. package/messages/ps.export.md +21 -1
  104. package/messages/ps.plan.md +55 -7
  105. package/messages/ps.validate.md +1 -1
  106. package/package.json +18 -14
package/LICENSE.md ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Isaac Ferreira
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md CHANGED
@@ -21,6 +21,7 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
21
21
  - [Modes](#modes)
22
22
  - [Validations](#validations)
23
23
  - [Commands](#commands)
24
+ - [GitHub Actions](#github-actions)
24
25
  - [Inspiration & equivalents](#inspiration--equivalents)
25
26
  - [Versioning](#versioning)
26
27
  - [Architecture](#architecture)
@@ -36,7 +37,7 @@ This plugin makes the desired state **declarative and reviewable**:
36
37
  - ✅ **Single source of truth:** the YAML in git is authoritative, and the org is reconciled to it.
37
38
  - ✅ **Plan before apply:** see exactly what will be added/removed before anything changes.
38
39
  - ✅ **Safe by default:** deletions are opt-in and guarded by a delete threshold.
39
- - ✅ **CI-native:** fully offline `check`, exit codes for gating, and `--json` on every command.
40
+ - ✅ **CI-native:** `check` needs no org, exit codes for gating, and `--json` on every command.
40
41
  - ✅ **Flexible at the edges:** pick your file layout (by permission set or by user) and your sync mode.
41
42
  - ✅ **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.
42
43
  - ✅ **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.
@@ -53,7 +54,7 @@ Or pin a version:
53
54
  sf plugins install sf-plugin-permission-sets@x.y.z
54
55
  ```
55
56
 
56
- Requires Salesforce CLI (`sf`) and Node.js 18+.
57
+ Requires Salesforce CLI (`sf`) and Node.js 20+.
57
58
 
58
59
  ## Quick start
59
60
 
@@ -61,7 +62,7 @@ Requires Salesforce CLI (`sf`) and Node.js 18+.
61
62
  # 1. Bootstrap YAML from an existing org (so you don't start from scratch)
62
63
  sf ps export --target-org dev --output-file permissions.yml
63
64
 
64
- # 2. Edit the files, commit, open a PR. Validate offline, no org needed:
65
+ # 2. Edit the files, commit, open a PR. Check them, no org needed:
65
66
  sf ps check --file "./permissions/*.yml"
66
67
 
67
68
  # 3. Validate against a real org (do the users/permission sets exist?)
@@ -79,7 +80,7 @@ sf ps apply --file "./permissions/*.yml" --target-org prod --mode sync
79
80
 
80
81
  ## Permission files
81
82
 
82
- You point every command at one or more YAML files with `--file` (alias `-f`).
83
+ `check`, `validate`, `plan`, and `apply` read one or more YAML files with `--file` (alias `-f`). (`export` writes YAML rather than reading it, so there `-f` is the output file.)
83
84
 
84
85
  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](#commands)), so there's no separate config format to learn yet. Each top-level key is unique within a file, and `check` flags duplicates.
85
86
 
@@ -130,7 +131,7 @@ users:
130
131
  expiration: 2026-09-30T00:00:00Z
131
132
  ```
132
133
 
133
- 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.
134
+ 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, which shows the `old → new` transition), 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.
134
135
 
135
136
  The `--file` flag is repeatable and the plugin expands globs itself, so all of these work:
136
137
 
@@ -187,19 +188,19 @@ A run performs three operations: **add** missing assignments, **update** changed
187
188
  | `additive` | ✅ | ✅ | ❌ | **Default.** Grant access, never revoke. Safe rollout. |
188
189
  | `destructive` | ❌ | ❌ | ✅ | Prune/revoke access that isn't declared, without granting anything new. |
189
190
 
190
- `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**.
191
+ `plan` and `apply` preview and act on exactly what the selected mode covers, so the body shows only those operations and what `plan` shows is what `apply` does. Anything the mode won't touch (an undeclared assignment under `additive`, a missing grant under `destructive`) is reported beneath the plan as **drift**, naming the mode that would include it. `sync` covers everything, so it never reports drift.
191
192
 
192
193
  ## Validations
193
194
 
194
- 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.
195
+ Every run checks the files first. `check` runs the file checks with no org, and `validate` adds the org-side checks. When files merge, most overlaps are unions rather than errors.
195
196
 
196
197
  | Situation | Checked by | Severity | Result |
197
198
  | --- | --- | :---: | --- |
198
- | Same user in two files with different targets | `check` (offline) | ✅ ok | Merged into one model, the point of slicing |
199
- | Same target listed twice for a user | `check` (offline) | ⚠️ warning | Deduped |
200
- | A user with no scopes, or an empty list | `check` (offline) | ⚠️ warning | Ignored as a no-op |
201
- | Same username key appears twice in one file | `check` (offline) | ❌ error | Rejected, the intent is ambiguous |
202
- | Declared user, permission set, group, or license missing or not unique | `validate` (online) | ❌ error | Run fails before any change |
199
+ | Same user in two files with different targets | `check` | ✅ ok | Merged into one model, the point of slicing |
200
+ | Same target listed twice for a user | `check` | ⚠️ warning | Deduped |
201
+ | A user with no scopes, or an empty list | `check` | ⚠️ warning | Ignored as a no-op |
202
+ | Same username key appears twice in one file | `check` | ❌ error | Rejected, the intent is ambiguous |
203
+ | Declared user, permission set, group, or license missing or not unique | `validate` | ❌ error | Run fails before any change |
203
204
 
204
205
  ## Commands
205
206
 
@@ -213,7 +214,7 @@ Every run checks the files first. `check` runs the offline checks with no org, a
213
214
 
214
215
  ### `sf ps check`
215
216
 
216
- Fully offline: runs in any CI job or pre-commit hook without org credentials.
217
+ Needs no org: runs in any CI job or pre-commit hook without org credentials.
217
218
 
218
219
  ```
219
220
  USAGE
@@ -241,7 +242,7 @@ FLAGS
241
242
  -o, --target-org=<org> (required) Org to resolve against.
242
243
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
243
244
 
244
- Runs all offline checks, then verifies that every user (active), permission set,
245
+ Runs everything `check` does, then verifies that every user (active), permission set,
245
246
  group, and license referenced actually exists and resolves uniquely.
246
247
  ```
247
248
 
@@ -249,71 +250,211 @@ group, and license referenced actually exists and resolves uniquely.
249
250
 
250
251
  ```
251
252
  USAGE
252
- $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--json]
253
+ $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged]
254
+ [--out <file>] [--json]
253
255
 
254
256
  FLAGS
255
257
  -o, --target-org=<org> (required)
256
258
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
257
259
  --mode=<value> additive | destructive | sync [default: additive]
260
+ --show-unchanged List assignments that already match, instead of only counting them.
261
+ --out=<file> Write the computed change set to a plan file that `apply` can run verbatim.
258
262
  ```
259
263
 
260
- Example output:
264
+ With `--out` the plan is also saved to a file: the resolved change set (adds, updates, removes with their record ids), the mode it was computed for, and the org it targets. Feed that file to `apply --plan` to execute exactly what you reviewed, with no recomputation. See [Saved plans](#saved-plans).
265
+
266
+ The body shows only what the mode will do, and unchanged assignments are summarized as a count (pass `--show-unchanged` to list them). The default `additive` run previews only what it grants, and reports the undeclared assignment it won't remove as drift:
261
267
 
262
268
  ```text
263
- $ sf ps plan -o prod --mode sync
269
+ $ sf ps plan -o prod -f "permissions/*.yml"
264
270
 
265
271
  Permission Set Assignments Plan
266
- Org: prod (00D5g0000000abcEAA) Mode: sync
272
+ Org: prod (00D5g0000000abcEAA) Mode: additive
267
273
 
268
- permissionSets:
274
+ Permission Sets
275
+ Report_Builder
276
+ + jdoe@acme.com
269
277
  Sales_Manager
270
278
  + asmith@acme.com
271
- ~ csmith@acme.com (expires 2026-12-31T23:59:59Z)
272
- - bwayne@acme.com (undeclared, will be removed)
273
- = jdoe@acme.com (no change)
279
+ ~ csmith@acme.com expires 2026-12-31T23:59:59Z → 2027-06-30T23:59:59Z
280
+
281
+ Plan: 2 to add, 1 to update. 3 users affected.
282
+ Drift: 1 undeclared assignment not removed in additive mode. Run --mode sync to remove it.
283
+ Unchanged: 4 assignments (--show-unchanged to list).
284
+
285
+ Next: sf ps apply -o prod -f "permissions/*.yml"
286
+ ```
287
+
288
+ The same files under `--mode sync` act on that drift too, so the removal now appears in the body and the drift line is gone:
289
+
290
+ ```text
291
+ $ sf ps plan -o prod -f "permissions/*.yml" --mode sync
292
+
293
+ Permission Set Assignments Plan
294
+ Org: prod (00D5g0000000abcEAA) Mode: sync
295
+
296
+ Permission Sets
274
297
  Report_Builder
275
298
  + jdoe@acme.com
299
+ Sales_Manager
300
+ + asmith@acme.com
301
+ ~ csmith@acme.com expires 2026-12-31T23:59:59Z → 2027-06-30T23:59:59Z
302
+ - bwayne@acme.com
276
303
 
277
- permissionSetGroups:
278
- Sales_Team_Bundle (no changes)
304
+ Plan: 2 to add, 1 to update, 1 to remove. 4 users affected.
305
+ Unchanged: 4 assignments (--show-unchanged to list).
279
306
 
280
- Plan: 2 to add, 1 to update, 1 to remove, 1 unchanged.
281
- ► Review, then run: sf ps apply -o prod --mode sync
307
+ Next: sf ps apply -o prod -f "permissions/*.yml" --mode sync
282
308
  ```
283
309
 
284
310
  ### `sf ps apply`
285
311
 
286
312
  ```
287
313
  USAGE
288
- $ sf ps apply -o <org> -f <glob>... [--mode <value>] [--max-deletes <n>]
289
- [--dry-run] [--no-prompt] [--json]
314
+ $ sf ps apply -o <org> (-f <glob>... | --plan <file>) [--mode <value>]
315
+ [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
290
316
 
291
317
  FLAGS
292
318
  -o, --target-org=<org> (required)
293
- -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
319
+ -f, --file=<glob>... YAML file(s) to read. Repeatable, globs expanded by the plugin.
320
+ --plan=<file> Apply a saved plan file from `plan --out` instead of re-reading YAML.
294
321
  --mode=<value> additive | destructive | sync [default: additive]
295
322
  --max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
296
323
  --dry-run Resolve and diff, print what would happen, change nothing.
324
+ --show-unchanged List assignments that already match, instead of only counting them.
297
325
  --no-prompt Skip the deletion confirmation prompt (for CI).
298
326
  ```
299
327
 
328
+ Provide exactly one source: `--file` (read and diff the YAML now) or `--plan` (run a saved plan). They cannot be combined, and `--mode` cannot accompany `--plan` (the plan already carries its mode). `--max-deletes`, `--dry-run`, and `--no-prompt` apply to both.
329
+
300
330
  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.
301
331
 
332
+ #### Saved plans
333
+
334
+ `plan --out` and `apply --plan` split review from execution, so what you approve is exactly what runs:
335
+
336
+ ```bash
337
+ sf ps plan -o prod -f "permissions/*.yml" --mode sync --out prod.plan
338
+ # ... review prod.plan, get sign-off ...
339
+ sf ps apply -o prod --plan prod.plan
340
+ ```
341
+
342
+ Without a saved plan, `apply` recomputes from the files: it re-reads the YAML, re-resolves every reference to an org id, and re-diffs against live state. Anything that changed since you ran `plan` (an edited file, a renamed permission set, another admin's assignment) silently changes what `apply` does. A saved plan freezes the resolved change set, so `apply --plan` executes those exact records with no recomputation.
343
+
344
+ Guardrails:
345
+
346
+ - `apply --plan` refuses a plan built for a different org (the plan records the org id).
347
+ - It refuses a plan file it cannot parse or whose format version it does not recognize.
348
+ - The plan is executed as recorded. If the org drifted after the plan was written, individual records may fail (a removed target, an already-deleted assignment); those surface as per-record failures in the outcome report rather than aborting the run. Re-run `plan` to get a fresh plan when in doubt.
349
+
302
350
  ### `sf ps export`
303
351
 
304
- Read-only. Snapshots the org's current assignments into a single YAML file you can commit and then feed back into the other commands.
352
+ Read-only. Snapshots the org's current assignments as YAML you can commit and then feed back into the other commands. Writes to a file with `--output-file`, or to stdout when that flag is omitted.
305
353
 
306
354
  ```
307
355
  USAGE
308
- $ sf ps export -o <org> --output-file <file> [--json]
356
+ $ sf ps export -o <org> [-f <file>] [--user <username>...]
357
+ [--kind <scope>...] [--json]
309
358
 
310
359
  FLAGS
311
360
  -o, --target-org=<org> (required) Org to read assignments from.
312
- --output-file=<file> (required) Path of the YAML file to write. Parent directories are created; an existing file is overwritten.
361
+ -f, --output-file=<file> Path of the YAML file to write. Parent directories are created; an existing file is overwritten. Omit to write to stdout.
362
+ --user=<username>... Only export these users. Repeatable, matched on exact username.
363
+ --kind=<scope>... Only export these scopes: permissionSets | permissionSetGroups | permissionSetLicenses. Repeatable.
313
364
  ```
314
365
 
315
366
  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.
316
367
 
368
+ With `--output-file` the command writes the file and prints a one-line summary. Omit the flag and the YAML goes to stdout instead, byte-for-byte identical to what the file would contain, so it pipes and diffs cleanly: in that mode only the document reaches stdout and warnings go to stderr, so there is nothing to strip. Under `--json` the envelope is the only thing on stdout, and when `--output-file` is omitted the document comes back in its `content` field.
369
+
370
+ ```bash
371
+ # Diff the org's live state against a committed snapshot
372
+ sf ps export -o prod | diff - permissions/prod.yml
373
+
374
+ # Redirect a scoped snapshot to a file of your choosing
375
+ sf ps export -o prod --user jdoe@acme.com > jdoe.yml
376
+ ```
377
+
378
+ By default the whole org is exported. `--user` and `--kind` narrow the snapshot: pass either to scope it down, and pass both to intersect (the named users, restricted to the named scopes). Values within a flag are a union, so `--user jdoe@acme.com --user asmith@acme.com` exports both. The `--kind` values are the same scope keys the file uses, so `--kind permissionSetLicenses` reads back exactly the `permissionSetLicenses:` block.
379
+
380
+ ```bash
381
+ # Snapshot one team's permission sets and groups only
382
+ sf ps export -o prod --output-file team.yml \
383
+ --user jdoe@acme.com --user asmith@acme.com \
384
+ --kind permissionSets --kind permissionSetGroups
385
+ ```
386
+
387
+ A requested `--user` that has no matching assignments (a typo, or a user who genuinely holds nothing in scope) is reported as a warning and the export continues with whoever matched, so a mistyped username never masquerades as a clean empty file.
388
+
389
+ ## GitHub Actions
390
+
391
+ Two dead-simple workflows: check pull requests to main with no org, then apply on merge.
392
+
393
+ **1. Check pull requests to main** (no org, no secrets):
394
+
395
+ ```yaml
396
+ # .github/workflows/permissions-check.yml
397
+ name: permissions-check
398
+
399
+ on:
400
+ pull_request:
401
+ branches: [main]
402
+
403
+ jobs:
404
+ check:
405
+ runs-on: ubuntu-latest
406
+ steps:
407
+ - uses: actions/checkout@v7
408
+ - uses: actions/setup-node@v7
409
+ with:
410
+ node-version: 20
411
+
412
+ - name: Install Salesforce CLI
413
+ run: npm install --global @salesforce/cli
414
+
415
+ - name: Install the plugin
416
+ run: sf plugins install sf-plugin-permission-sets
417
+
418
+ - name: Check the permission files
419
+ run: sf ps check --file "permissions/*.yml"
420
+ ```
421
+
422
+ **2. Apply on merge to main** (needs org auth):
423
+
424
+ ```yaml
425
+ # .github/workflows/permissions-apply.yml
426
+ name: permissions-apply
427
+
428
+ on:
429
+ push:
430
+ branches: [main]
431
+
432
+ jobs:
433
+ apply:
434
+ runs-on: ubuntu-latest
435
+ steps:
436
+ - uses: actions/checkout@v7
437
+ - uses: actions/setup-node@v7
438
+ with:
439
+ node-version: 20
440
+
441
+ - name: Install Salesforce CLI
442
+ run: npm install --global @salesforce/cli
443
+
444
+ - name: Install the plugin
445
+ run: sf plugins install sf-plugin-permission-sets
446
+
447
+ - name: Log in to the org
448
+ run: echo '${{ secrets.SFDX_AUTH_URL }}' | sf org login sfdx-url --sfdx-url-stdin --alias prod
449
+
450
+ - name: Apply the assignments
451
+ run: sf ps apply --file "permissions/*.yml" --target-org prod --mode sync --no-prompt
452
+ ```
453
+
454
+ Get the auth URL once with `sf org display --verbose --target-org prod`, copy the `Sfdx Auth Url` value, and save it as a repository secret named `SFDX_AUTH_URL`.
455
+
456
+ Want the diff on the PR before merging? Add a `sf ps plan --file "permissions/*.yml" --target-org prod` step (it needs the same org auth) to the check workflow.
457
+
317
458
  ## Inspiration & equivalents
318
459
 
319
460
  This plugin's command surface borrows ideas from tools you already know:
@@ -354,11 +495,8 @@ gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
354
495
  | dist-tag | Published by | Install |
355
496
  | --- | --- | --- |
356
497
  | `latest` | manual release with a normal tag like `v1.2.0` | `sf plugins install sf-plugin-permission-sets` |
357
- | `next` | manual release with a hyphenated tag like `v1.3.0-beta.1` | `sf plugins install sf-plugin-permission-sets@next` |
358
498
  | `dev` | automatic on every push to `main` | `sf plugins install sf-plugin-permission-sets@dev` |
359
499
 
360
- The `next` tag is selected whenever the version contains a hyphen, not by GitHub's prerelease checkbox.
361
-
362
500
  ## Architecture
363
501
 
364
502
  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.
@@ -379,9 +517,11 @@ The plugin is layered so every command reuses the same core. Commands stay thin,
379
517
  | `load` | Expand globs, run parse then validate then normalize per file, and merge by union. |
380
518
  | `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. |
381
519
  | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
520
+ | `mode` | Scope a diff to what a reconcile mode acts on, plus the drift it leaves alone. |
382
521
  | `report` | Format a diff as a plan. |
522
+ | `plan-file` | Serialize and parse a saved plan: the frozen change set `apply --plan` runs. |
383
523
 
384
- 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 **fetch**es the org's current assignments through the port and **serialize**s 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.
524
+ Commands are slices of one pipeline. `check` runs the **load** stage only, with no org. `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 **fetch**es the org's current assignments through the port and **serialize**s 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. `plan --out` freezes that resolved change set to a file, and `apply --plan` runs it verbatim, skipping load, resolve, and diff.
385
525
 
386
526
  ## License
387
527
 
@@ -1,14 +1,18 @@
1
1
  import { Connection } from '@salesforce/core';
2
- import { ActualAssignment, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, Kind, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/model.js';
3
- import { OrgClient } from '../services/adapters/org-client.js';
2
+ import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, Kind, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/index.js';
3
+ import { OrgClient } from '../services/adapters/index.js';
4
4
  /** Adapter backing OrgClient with a Salesforce Connection. autoFetchQuery pages past 2000 rows. */
5
5
  export declare class ConnectionOrgClient implements OrgClient {
6
6
  private readonly connection;
7
7
  constructor(connection: Connection);
8
8
  findUsers(usernames: string[]): Promise<OrgUser[]>;
9
9
  findTargets(kind: Kind, names: string[]): Promise<OrgTarget[]>;
10
- listAssignments(): Promise<DesiredAssignment[]>;
10
+ listAssignments(filter?: AssignmentFilter): Promise<DesiredAssignment[]>;
11
+ private listMemberships;
12
+ private listLicenses;
11
13
  currentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
14
+ private membershipAssignments;
15
+ private licenseAssignments;
12
16
  addAssignments(additions: ResolvedAddition[]): Promise<AssignmentOutcome[]>;
13
17
  updateAssignments(updates: AssignmentUpdate[]): Promise<AssignmentOutcome[]>;
14
18
  removeAssignments(removals: ActualAssignment[]): Promise<AssignmentOutcome[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"connection-org-client.d.ts","sourceRoot":"","sources":["../../src/adapters/connection-org-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACH,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,IAAI,EACJ,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,oCAAoC,CAAC;AA6J/D,mGAAmG;AACnG,qBAAa,mBAAoB,YAAW,SAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAE7C,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAQlD,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAS9D,eAAe,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC;IA8C/C,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IA0DrE,cAAc,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAoB3E,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAoB5E,iBAAiB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAuB5E,KAAK;CAItB"}
1
+ {"version":3,"file":"connection-org-client.d.ts","sourceRoot":"","sources":["../../src/adapters/connection-org-client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACH,gBAAgB,EAChB,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,iBAAiB,EACjB,IAAI,EACJ,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AA6J1D,mGAAmG;AACnG,qBAAa,mBAAoB,YAAW,SAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,UAAU;gBAAV,UAAU,EAAE,UAAU;IAE7C,SAAS,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC;IAQlD,WAAW,CAAC,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAS9D,eAAe,CAAC,MAAM,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAkBvE,eAAe;YAoCf,YAAY;IAgBb,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YA4BpE,qBAAqB;YAsBrB,kBAAkB;IAUnB,cAAc,CAAC,SAAS,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAkB3E,iBAAiB,CAAC,OAAO,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAkB5E,iBAAiB,CAAC,QAAQ,EAAE,gBAAgB,EAAE,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAmB5E,KAAK;CAItB"}
@@ -116,43 +116,64 @@ export class ConnectionOrgClient {
116
116
  const records = await this.query(`SELECT Id, ${field} FROM ${sobject} WHERE ${field} IN (${inList(names)})`);
117
117
  return records.map((record) => ({ id: record.Id, name: record[field] }));
118
118
  }
119
- async listAssignments() {
120
- const [memberships, licenses] = await Promise.all([
121
- this.query('SELECT Id, Assignee.Username, PermissionSet.Name, PermissionSetGroup.DeveloperName, PermissionSetGroupId, ExpirationDate ' +
122
- 'FROM PermissionSetAssignment ' +
123
- 'WHERE Assignee.IsActive = true AND PermissionSet.IsOwnedByProfile = false'),
124
- this.query('SELECT Id, Assignee.Username, PermissionSetLicense.DeveloperName ' +
125
- 'FROM PermissionSetLicenseAssign ' +
126
- 'WHERE Assignee.IsActive = true'),
127
- ]);
128
- const assignments = [];
129
- for (const record of memberships) {
119
+ async listAssignments(filter) {
120
+ const kinds = filter?.kinds;
121
+ const wantsPermissionSet = !kinds || kinds.includes('permissionSet');
122
+ const wantsGroup = !kinds || kinds.includes('permissionSetGroup');
123
+ const wantsLicense = !kinds || kinds.includes('permissionSetLicense');
124
+ const tasks = [];
125
+ if (wantsPermissionSet || wantsGroup) {
126
+ tasks.push(this.listMemberships(filter?.usernames, wantsPermissionSet, wantsGroup));
127
+ }
128
+ if (wantsLicense) {
129
+ tasks.push(this.listLicenses(filter?.usernames));
130
+ }
131
+ const results = await Promise.all(tasks);
132
+ return results.flat();
133
+ }
134
+ async listMemberships(usernames, wantsPermissionSet, wantsGroup) {
135
+ const clauses = [
136
+ 'Assignee.IsActive = true',
137
+ 'PermissionSet.IsOwnedByProfile = false',
138
+ ];
139
+ if (usernames)
140
+ clauses.push(`Assignee.Username IN (${inList(usernames)})`);
141
+ if (!wantsGroup)
142
+ clauses.push('PermissionSetGroupId = null');
143
+ if (!wantsPermissionSet)
144
+ clauses.push('PermissionSetGroupId != null');
145
+ const soql = 'SELECT Id, Assignee.Username, PermissionSet.Name, PermissionSetGroup.DeveloperName, PermissionSetGroupId, ExpirationDate ' +
146
+ `FROM PermissionSetAssignment WHERE ${clauses.join(' AND ')}`;
147
+ const records = await this.query(soql);
148
+ return records.map((record) => {
130
149
  const expiration = record.ExpirationDate ? { expiration: record.ExpirationDate } : {};
131
- if (record.PermissionSetGroupId && record.PermissionSetGroup) {
132
- assignments.push({
150
+ return record.PermissionSetGroupId && record.PermissionSetGroup
151
+ ? {
133
152
  assignee: record.Assignee.Username,
134
153
  kind: 'permissionSetGroup',
135
154
  target: record.PermissionSetGroup.DeveloperName,
136
155
  ...expiration,
137
- });
138
- }
139
- else {
140
- assignments.push({
156
+ }
157
+ : {
141
158
  assignee: record.Assignee.Username,
142
159
  kind: 'permissionSet',
143
160
  target: record.PermissionSet.Name,
144
161
  ...expiration,
145
- });
146
- }
147
- }
148
- for (const record of licenses) {
149
- assignments.push({
150
- assignee: record.Assignee.Username,
151
- kind: 'permissionSetLicense',
152
- target: record.PermissionSetLicense.DeveloperName,
153
- });
154
- }
155
- return assignments;
162
+ };
163
+ });
164
+ }
165
+ async listLicenses(usernames) {
166
+ const clauses = ['Assignee.IsActive = true'];
167
+ if (usernames)
168
+ clauses.push(`Assignee.Username IN (${inList(usernames)})`);
169
+ const soql = 'SELECT Id, Assignee.Username, PermissionSetLicense.DeveloperName ' +
170
+ `FROM PermissionSetLicenseAssign WHERE ${clauses.join(' AND ')}`;
171
+ const records = await this.query(soql);
172
+ return records.map((record) => ({
173
+ assignee: record.Assignee.Username,
174
+ kind: 'permissionSetLicense',
175
+ target: record.PermissionSetLicense.DeveloperName,
176
+ }));
156
177
  }
157
178
  async currentAssignments(targets) {
158
179
  const permissionSetIds = targets.filter((ref) => ref.kind === 'permissionSet').map((ref) => ref.id);
@@ -167,44 +188,52 @@ export class ConnectionOrgClient {
167
188
  if (memberClauses.length > 0) {
168
189
  const soql = 'SELECT Id, Assignee.Username, PermissionSet.Name, PermissionSetGroup.DeveloperName, PermissionSetGroupId, ExpirationDate ' +
169
190
  `FROM PermissionSetAssignment WHERE ${memberClauses.join(' OR ')}`;
170
- tasks.push(this.query(soql).then((records) => records.map((record) => {
171
- const expiration = record.ExpirationDate ? { expiration: record.ExpirationDate } : {};
172
- return record.PermissionSetGroupId && record.PermissionSetGroup
173
- ? {
174
- recordId: record.Id,
175
- assignee: record.Assignee.Username,
176
- kind: 'permissionSetGroup',
177
- target: record.PermissionSetGroup.DeveloperName,
178
- ...expiration,
179
- }
180
- : {
181
- recordId: record.Id,
182
- assignee: record.Assignee.Username,
183
- kind: 'permissionSet',
184
- target: record.PermissionSet.Name,
185
- ...expiration,
186
- };
187
- })));
191
+ tasks.push(this.membershipAssignments(soql));
188
192
  }
189
193
  if (licenseIds.length > 0) {
190
194
  const soql = 'SELECT Id, Assignee.Username, PermissionSetLicense.DeveloperName ' +
191
195
  `FROM PermissionSetLicenseAssign WHERE PermissionSetLicenseId IN (${inList(licenseIds)})`;
192
- tasks.push(this.query(soql).then((records) => records.map((record) => ({
193
- recordId: record.Id,
194
- assignee: record.Assignee.Username,
195
- kind: 'permissionSetLicense',
196
- target: record.PermissionSetLicense.DeveloperName,
197
- }))));
196
+ tasks.push(this.licenseAssignments(soql));
198
197
  }
199
198
  const results = await Promise.all(tasks);
200
199
  return results.flat();
201
200
  }
201
+ async membershipAssignments(soql) {
202
+ const records = await this.query(soql);
203
+ return records.map((record) => {
204
+ const expiration = record.ExpirationDate ? { expiration: record.ExpirationDate } : {};
205
+ return record.PermissionSetGroupId && record.PermissionSetGroup
206
+ ? {
207
+ recordId: record.Id,
208
+ assignee: record.Assignee.Username,
209
+ kind: 'permissionSetGroup',
210
+ target: record.PermissionSetGroup.DeveloperName,
211
+ ...expiration,
212
+ }
213
+ : {
214
+ recordId: record.Id,
215
+ assignee: record.Assignee.Username,
216
+ kind: 'permissionSet',
217
+ target: record.PermissionSet.Name,
218
+ ...expiration,
219
+ };
220
+ });
221
+ }
222
+ async licenseAssignments(soql) {
223
+ const records = await this.query(soql);
224
+ return records.map((record) => ({
225
+ recordId: record.Id,
226
+ assignee: record.Assignee.Username,
227
+ kind: 'permissionSetLicense',
228
+ target: record.PermissionSetLicense.DeveloperName,
229
+ }));
230
+ }
202
231
  async addAssignments(additions) {
203
232
  const batches = additionBatches(additions);
204
- const settled = await Promise.all(batches.map((batch) => this.connection.create(batch.sobject, batch.records, { allOrNone: false }).then((results) => ({
205
- batch,
206
- results: results,
207
- }))));
233
+ const settled = await Promise.all(batches.map(async (batch) => {
234
+ const results = await this.connection.create(batch.sobject, batch.records, { allOrNone: false });
235
+ return { batch, results: results };
236
+ }));
208
237
  const outcomes = [];
209
238
  for (const { batch, results } of settled) {
210
239
  batch.additions.forEach((addition, index) => {
@@ -215,10 +244,10 @@ export class ConnectionOrgClient {
215
244
  }
216
245
  async updateAssignments(updates) {
217
246
  const batches = updateBatches(updates);
218
- const settled = await Promise.all(batches.map((batch) => this.connection.update(batch.sobject, batch.records, { allOrNone: false }).then((results) => ({
219
- batch,
220
- results: results,
221
- }))));
247
+ const settled = await Promise.all(batches.map(async (batch) => {
248
+ const results = await this.connection.update(batch.sobject, batch.records, { allOrNone: false });
249
+ return { batch, results: results };
250
+ }));
222
251
  const outcomes = [];
223
252
  for (const { batch, results } of settled) {
224
253
  batch.updates.forEach((update, index) => {
@@ -229,9 +258,11 @@ export class ConnectionOrgClient {
229
258
  }
230
259
  async removeAssignments(removals) {
231
260
  const batches = removalBatches(removals);
232
- const settled = await Promise.all(batches.map((batch) => this.connection
233
- .destroy(batch.sobject, batch.removals.map((removal) => removal.recordId), { allOrNone: false })
234
- .then((results) => ({ batch, results: results }))));
261
+ const settled = await Promise.all(batches.map(async (batch) => {
262
+ const recordIds = batch.removals.map((removal) => removal.recordId);
263
+ const results = await this.connection.destroy(batch.sobject, recordIds, { allOrNone: false });
264
+ return { batch, results: results };
265
+ }));
235
266
  const outcomes = [];
236
267
  for (const { batch, results } of settled) {
237
268
  batch.removals.forEach((removal, index) => {