sf-plugin-permission-sets 0.0.0-dev.6 → 0.0.0-dev.61

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 (128) hide show
  1. package/LICENSE.md +28 -0
  2. package/README.md +243 -85
  3. package/lib/adapters/connection-org-client.d.ts +21 -0
  4. package/lib/adapters/connection-org-client.d.ts.map +1 -0
  5. package/lib/adapters/connection-org-client.js +265 -0
  6. package/lib/adapters/connection-org-client.js.map +1 -0
  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 +41 -0
  12. package/lib/commands/ps/apply.d.ts.map +1 -0
  13. package/lib/commands/ps/apply.js +178 -0
  14. package/lib/commands/ps/apply.js.map +1 -0
  15. package/lib/commands/ps/check.d.ts +21 -0
  16. package/lib/commands/ps/check.d.ts.map +1 -0
  17. package/lib/commands/ps/check.js +55 -0
  18. package/lib/commands/ps/check.js.map +1 -0
  19. package/lib/commands/ps/export.d.ts +26 -0
  20. package/lib/commands/ps/export.d.ts.map +1 -0
  21. package/lib/commands/ps/export.js +74 -0
  22. package/lib/commands/ps/export.js.map +1 -0
  23. package/lib/commands/ps/plan.d.ts +61 -0
  24. package/lib/commands/ps/plan.d.ts.map +1 -0
  25. package/lib/commands/ps/plan.js +212 -0
  26. package/lib/commands/ps/plan.js.map +1 -0
  27. package/lib/commands/ps/validate.d.ts +21 -0
  28. package/lib/commands/ps/validate.d.ts.map +1 -0
  29. package/lib/commands/ps/validate.js +56 -0
  30. package/lib/commands/ps/validate.js.map +1 -0
  31. package/lib/core/diff.d.ts +10 -0
  32. package/lib/core/diff.d.ts.map +1 -0
  33. package/lib/core/diff.js +54 -0
  34. package/lib/core/diff.js.map +1 -0
  35. package/lib/core/finding.d.ts +41 -0
  36. package/lib/core/finding.d.ts.map +1 -0
  37. package/lib/core/finding.js +76 -0
  38. package/lib/core/finding.js.map +1 -0
  39. package/lib/core/index.d.ts +10 -0
  40. package/lib/core/index.d.ts.map +1 -0
  41. package/lib/core/index.js +9 -0
  42. package/lib/core/index.js.map +1 -0
  43. package/lib/core/load.d.ts +4 -0
  44. package/lib/core/load.d.ts.map +1 -0
  45. package/lib/core/load.js +60 -0
  46. package/lib/core/load.js.map +1 -0
  47. package/lib/core/mode.d.ts +18 -0
  48. package/lib/core/mode.d.ts.map +1 -0
  49. package/lib/core/mode.js +16 -0
  50. package/lib/core/mode.js.map +1 -0
  51. package/lib/core/model.d.ts +78 -0
  52. package/lib/core/model.d.ts.map +1 -0
  53. package/lib/core/model.js +2 -0
  54. package/lib/core/model.js.map +1 -0
  55. package/lib/core/normalize.d.ts +17 -0
  56. package/lib/core/normalize.d.ts.map +1 -0
  57. package/lib/core/normalize.js +72 -0
  58. package/lib/core/normalize.js.map +1 -0
  59. package/lib/core/parse.d.ts +10 -0
  60. package/lib/core/parse.d.ts.map +1 -0
  61. package/lib/core/parse.js +20 -0
  62. package/lib/core/parse.js.map +1 -0
  63. package/lib/core/report.d.ts +15 -0
  64. package/lib/core/report.d.ts.map +1 -0
  65. package/lib/core/report.js +122 -0
  66. package/lib/core/report.js.map +1 -0
  67. package/lib/core/resolve.d.ts +20 -0
  68. package/lib/core/resolve.d.ts.map +1 -0
  69. package/lib/core/resolve.js +88 -0
  70. package/lib/core/resolve.js.map +1 -0
  71. package/lib/core/schema.d.ts +23 -0
  72. package/lib/core/schema.d.ts.map +1 -0
  73. package/lib/core/schema.js +35 -0
  74. package/lib/core/schema.js.map +1 -0
  75. package/lib/core/serialize.d.ts +9 -0
  76. package/lib/core/serialize.d.ts.map +1 -0
  77. package/lib/core/serialize.js +36 -0
  78. package/lib/core/serialize.js.map +1 -0
  79. package/lib/index.d.ts +1 -0
  80. package/lib/index.d.ts.map +1 -0
  81. package/lib/services/adapters/index.d.ts +2 -0
  82. package/lib/services/adapters/index.d.ts.map +1 -0
  83. package/lib/services/adapters/index.js +2 -0
  84. package/lib/services/adapters/index.js.map +1 -0
  85. package/lib/services/adapters/org-client.d.ts +22 -0
  86. package/lib/services/adapters/org-client.d.ts.map +1 -0
  87. package/lib/services/adapters/org-client.js +2 -0
  88. package/lib/services/adapters/org-client.js.map +1 -0
  89. package/lib/services/apply.d.ts +39 -0
  90. package/lib/services/apply.d.ts.map +1 -0
  91. package/lib/services/apply.js +85 -0
  92. package/lib/services/apply.js.map +1 -0
  93. package/lib/services/check.d.ts +14 -0
  94. package/lib/services/check.d.ts.map +1 -0
  95. package/lib/services/check.js +18 -0
  96. package/lib/services/check.js.map +1 -0
  97. package/lib/services/export.d.ts +19 -0
  98. package/lib/services/export.d.ts.map +1 -0
  99. package/lib/services/export.js +29 -0
  100. package/lib/services/export.js.map +1 -0
  101. package/lib/services/index.d.ts +6 -0
  102. package/lib/services/index.d.ts.map +1 -0
  103. package/lib/services/index.js +6 -0
  104. package/lib/services/index.js.map +1 -0
  105. package/lib/services/plan.d.ts +29 -0
  106. package/lib/services/plan.d.ts.map +1 -0
  107. package/lib/services/plan.js +51 -0
  108. package/lib/services/plan.js.map +1 -0
  109. package/lib/services/resolution.d.ts +17 -0
  110. package/lib/services/resolution.d.ts.map +1 -0
  111. package/lib/services/resolution.js +48 -0
  112. package/lib/services/resolution.js.map +1 -0
  113. package/lib/services/validate.d.ts +21 -0
  114. package/lib/services/validate.d.ts.map +1 -0
  115. package/lib/services/validate.js +50 -0
  116. package/lib/services/validate.js.map +1 -0
  117. package/messages/ps.apply.md +85 -0
  118. package/messages/ps.check.md +33 -0
  119. package/messages/ps.export.md +45 -0
  120. package/messages/ps.plan.md +85 -0
  121. package/messages/ps.validate.md +29 -0
  122. package/package.json +115 -180
  123. package/lib/commands/ps/info.d.ts +0 -11
  124. package/lib/commands/ps/info.js +0 -17
  125. package/lib/commands/ps/info.js.map +0 -1
  126. package/messages/ps.info.md +0 -17
  127. package/oclif.lock +0 -7781
  128. package/oclif.manifest.json +0 -51
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
@@ -1,10 +1,12 @@
1
1
  # sf-plugin-permission-sets
2
2
 
3
- [![NPM](https://img.shields.io/npm/v/sf-plugin-permission-sets.svg?label=sf-plugin-permission-sets)](https://www.npmjs.com/package/sf-plugin-permission-sets) [![Downloads/week](https://img.shields.io/npm/dw/sf-plugin-permission-sets.svg)](https://npmjs.org/package/sf-plugin-permission-sets) [![License](https://img.shields.io/badge/License-BSD%203--Clause-brightgreen.svg)](https://raw.githubusercontent.com/zaclummys/sf-plugin-permission-sets/main/LICENSE.txt)
3
+ [![NPM](https://img.shields.io/npm/v/sf-plugin-permission-sets.svg?label=sf-plugin-permission-sets)](https://www.npmjs.com/package/sf-plugin-permission-sets) [![Downloads/week](https://img.shields.io/npm/dw/sf-plugin-permission-sets.svg)](https://npmjs.org/package/sf-plugin-permission-sets) [![Stability: experimental](https://img.shields.io/badge/stability-experimental-orange.svg)](https://semver.org/#spec-item-4) [![License](https://img.shields.io/badge/License-BSD%203--Clause-brightgreen.svg)](https://raw.githubusercontent.com/zaclummys/sf-plugin-permission-sets/main/LICENSE.txt)
4
4
 
5
5
  > Declarative, GitOps-style management of **permission set assignments** for Salesforce orgs.
6
6
  > Define who gets what in version-controlled YAML. The plugin reconciles your org to match it: `plan` then `apply`, just like Terraform.
7
7
 
8
+ > ⚠️ **Under active development.** This plugin is `0.x`. Per [semver's major-version-zero rule](https://semver.org/#spec-item-4), 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`.
9
+
8
10
  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.
9
11
 
10
12
  ---
@@ -17,23 +19,28 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
17
19
  - [Permission files](#permission-files)
18
20
  - [Organizing files](#organizing-files)
19
21
  - [Modes](#modes)
22
+ - [Validations](#validations)
20
23
  - [Commands](#commands)
21
- - [CI/CD](#cicd)
24
+ - [GitHub Actions](#github-actions)
22
25
  - [Inspiration & equivalents](#inspiration--equivalents)
26
+ - [Versioning](#versioning)
27
+ - [Architecture](#architecture)
23
28
 
24
29
  ---
25
30
 
26
31
  ## Why
27
32
 
28
- 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.
33
+ 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.
29
34
 
30
35
  This plugin makes the desired state **declarative and reviewable**:
31
36
 
32
37
  - ✅ **Single source of truth:** the YAML in git is authoritative, and the org is reconciled to it.
33
38
  - ✅ **Plan before apply:** see exactly what will be added/removed before anything changes.
34
39
  - ✅ **Safe by default:** deletions are opt-in and guarded by a delete threshold.
35
- - ✅ **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.
36
41
  - ✅ **Flexible at the edges:** pick your file layout (by permission set or by user) and your sync mode.
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.
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.
37
44
 
38
45
  ## Install
39
46
 
@@ -47,15 +54,15 @@ Or pin a version:
47
54
  sf plugins install sf-plugin-permission-sets@x.y.z
48
55
  ```
49
56
 
50
- Requires Salesforce CLI (`sf`) and Node.js 18+.
57
+ Requires Salesforce CLI (`sf`) and Node.js 20+.
51
58
 
52
59
  ## Quick start
53
60
 
54
61
  ```bash
55
62
  # 1. Bootstrap YAML from an existing org (so you don't start from scratch)
56
- sf ps export --target-org dev --output-dir permissions
63
+ sf ps export --target-org dev --output-file permissions.yml
57
64
 
58
- # 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:
59
66
  sf ps check --file "./permissions/*.yml"
60
67
 
61
68
  # 3. Validate against a real org (do the users/permission sets exist?)
@@ -73,7 +80,7 @@ sf ps apply --file "./permissions/*.yml" --target-org prod --mode sync
73
80
 
74
81
  ## Permission files
75
82
 
76
- 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.)
77
84
 
78
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.
79
86
 
@@ -108,6 +115,24 @@ users:
108
115
  - Sales_Manager
109
116
  ```
110
117
 
118
+ ### Timed access (expiration)
119
+
120
+ 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.
121
+
122
+ ```yaml
123
+ users:
124
+ contractor@acme.com:
125
+ permissionSets:
126
+ - Read_Only # permanent
127
+ - name: Sales_Manager # expires automatically
128
+ expiration: 2026-12-31T23:59:59Z
129
+ permissionSetGroups:
130
+ - name: Project_Phoenix_Bundle
131
+ expiration: 2026-09-30T00:00:00Z
132
+ ```
133
+
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.
135
+
111
136
  The `--file` flag is repeatable and the plugin expands globs itself, so all of these work:
112
137
 
113
138
  ```bash
@@ -155,15 +180,27 @@ The two compose: a directory per environment, each split into functional files.
155
180
 
156
181
  ## Modes
157
182
 
158
- A run performs two atomic operations: **add** missing assignments and **remove** undeclared ones. The mode selects which it actually executes. Set it with `--mode` (default `additive`):
183
+ 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`):
159
184
 
160
- | Mode | Adds missing | Removes undeclared | Use when… |
161
- | ------------- | :----------: | :----------------: | --------------------------------------------------------------------- |
162
- | `additive` | ✅ | | **Default.** Grant access, never revoke. Safe rollout. |
163
- | `destructive` | | ✅ | Prune/revoke access that isn't declared, without granting anything new. |
164
- | `sync` | | ✅ | Full reconcile: make the org exactly match the YAML (`sync` = `additive` + `destructive`). |
185
+ | Mode | Adds missing | Updates expirations | Removes undeclared | Use when… |
186
+ | ------------- | :----------: | :-----------------: | :----------------: | --------------------------------------------------------------------- |
187
+ | `sync` | ✅ | ✅ | ✅ | Full reconcile: make the org exactly match the YAML (`sync` = `additive` + `destructive`). |
188
+ | `additive` | | ✅ | ❌ | **Default.** Grant access, never revoke. Safe rollout. |
189
+ | `destructive` | | ❌ | ✅ | Prune/revoke access that isn't declared, without granting anything new. |
165
190
 
166
- `plan` always shows the *full* picture (both adds **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**. Gate CI on it with `--fail-on-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.
192
+
193
+ ## Validations
194
+
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.
196
+
197
+ | Situation | Checked by | Severity | Result |
198
+ | --- | --- | :---: | --- |
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 |
167
204
 
168
205
  ## Commands
169
206
 
@@ -171,13 +208,13 @@ A run performs two atomic operations: **add** missing assignments and **remove**
171
208
  | ---------------- | ---------------------------------------------------------------------- |
172
209
  | `sf ps check` | Static analysis of the files alone: schema, duplicates, conflicts, identifier shape. No org, no auth. |
173
210
  | `sf ps validate` | Everything `check` does, plus resolving every user/permission set against the org. |
174
- | `sf ps plan` | Compute and display the change set. Optionally fail on drift. |
211
+ | `sf ps plan` | Compute and display the change set: a read-only preview of what `apply` would do. |
175
212
  | `sf ps apply` | Reconcile the org. Honors `--mode`, prompts before deletes, enforces guardrails. |
176
213
  | `sf ps export` | Generate YAML from the current org state to bootstrap adoption. |
177
214
 
178
215
  ### `sf ps check`
179
216
 
180
- 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.
181
218
 
182
219
  ```
183
220
  USAGE
@@ -188,11 +225,11 @@ FLAGS
188
225
  --strict Treat warnings as errors.
189
226
 
190
227
  CHECKS
191
- valid YAML & schema (unknown keys rejected)
192
- duplicate assignees / duplicate (user, target) pairs
193
- conflicting intent across files
194
- empty or malformed assignee usernames
195
- internal referential integrity
228
+ - valid YAML & schema (unknown keys rejected)
229
+ - duplicate assignees / duplicate (user, target) pairs
230
+ - conflicting intent across files
231
+ - empty or malformed assignee usernames
232
+ - internal referential integrity
196
233
  ```
197
234
 
198
235
  ### `sf ps validate`
@@ -205,7 +242,7 @@ FLAGS
205
242
  -o, --target-org=<org> (required) Org to resolve against.
206
243
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
207
244
 
208
- 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,
209
246
  group, and license referenced actually exists and resolves uniquely.
210
247
  ```
211
248
 
@@ -213,44 +250,65 @@ group, and license referenced actually exists and resolves uniquely.
213
250
 
214
251
  ```
215
252
  USAGE
216
- $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--fail-on-drift] [--json]
253
+ $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged] [--json]
217
254
 
218
255
  FLAGS
219
256
  -o, --target-org=<org> (required)
220
257
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
221
258
  --mode=<value> additive | destructive | sync [default: additive]
222
- --fail-on-drift Exit non-zero if any change is pending (for CI gates).
259
+ --show-unchanged List assignments that already match, instead of only counting them.
223
260
  ```
224
261
 
225
- Example output:
262
+ 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:
226
263
 
227
264
  ```text
228
- $ sf ps plan -o prod --mode sync
265
+ $ sf ps plan -o prod -f "permissions/*.yml"
229
266
 
230
267
  Permission Set Assignments Plan
231
- Org: prod (00D5g0000000abcEAA) Mode: sync
268
+ Org: prod (00D5g0000000abcEAA) Mode: additive
232
269
 
233
- permissionSets:
270
+ Permission Sets
271
+ Report_Builder
272
+ + jdoe@acme.com
234
273
  Sales_Manager
235
274
  + asmith@acme.com
236
- - bwayne@acme.com (undeclared, will be removed)
237
- = jdoe@acme.com (no change)
275
+ ~ csmith@acme.com expires 2026-12-31T23:59:59Z 2027-06-30T23:59:59Z
276
+
277
+ Plan: 2 to add, 1 to update. 3 users affected.
278
+ Drift: 1 undeclared assignment not removed in additive mode. Run --mode sync to remove it.
279
+ Unchanged: 4 assignments (--show-unchanged to list).
280
+
281
+ Next: sf ps apply -o prod -f "permissions/*.yml"
282
+ ```
283
+
284
+ 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:
285
+
286
+ ```text
287
+ $ sf ps plan -o prod -f "permissions/*.yml" --mode sync
288
+
289
+ Permission Set Assignments Plan
290
+ Org: prod (00D5g0000000abcEAA) Mode: sync
291
+
292
+ Permission Sets
238
293
  Report_Builder
239
294
  + jdoe@acme.com
295
+ Sales_Manager
296
+ + asmith@acme.com
297
+ ~ csmith@acme.com expires 2026-12-31T23:59:59Z → 2027-06-30T23:59:59Z
298
+ - bwayne@acme.com
240
299
 
241
- permissionSetGroups:
242
- Sales_Team_Bundle (no changes)
300
+ Plan: 2 to add, 1 to update, 1 to remove. 4 users affected.
301
+ Unchanged: 4 assignments (--show-unchanged to list).
243
302
 
244
- Plan: 2 to add, 1 to remove, 1 unchanged.
245
- ► Review, then run: sf ps apply -o prod --mode sync
303
+ Next: sf ps apply -o prod -f "permissions/*.yml" --mode sync
246
304
  ```
247
305
 
248
306
  ### `sf ps apply`
249
307
 
250
308
  ```
251
309
  USAGE
252
- $ sf ps apply -o <org> -f <glob>... [--mode <value>] [--max-deletes <n>]
253
- [--dry-run] [--no-prompt] [--json]
310
+ $ sf ps apply -o <org> -f <glob>... [--mode <value>]
311
+ [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
254
312
 
255
313
  FLAGS
256
314
  -o, --target-org=<org> (required)
@@ -258,88 +316,188 @@ FLAGS
258
316
  --mode=<value> additive | destructive | sync [default: additive]
259
317
  --max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
260
318
  --dry-run Resolve and diff, print what would happen, change nothing.
319
+ --show-unchanged List assignments that already match, instead of only counting them.
261
320
  --no-prompt Skip the deletion confirmation prompt (for CI).
262
321
  ```
263
322
 
323
+ `apply` recomputes from the files every run: it re-reads the YAML, re-resolves every reference to an org id, and re-diffs against live state, then acts per `--mode`. Run `plan` shortly before `apply` so the preview you review reflects what `apply` will do (an edited file, a renamed permission set, or another admin's change between the two shifts the outcome).
324
+
264
325
  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.
265
326
 
266
327
  ### `sf ps export`
267
328
 
329
+ 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.
330
+
268
331
  ```
269
332
  USAGE
270
- $ sf ps export -o <org> [--output-dir <dir>] [--layout <value>]
271
- [--permission-sets <names>] [--json]
333
+ $ sf ps export -o <org> [-f <file>] [--user <username>...]
334
+ [--kind <scope>...] [--json]
272
335
 
273
336
  FLAGS
274
- -o, --target-org=<org> (required)
275
- --output-dir=<dir> [default: permissions] Where to write the generated YAML.
276
- --layout=<value> by-permission-set | by-user [default: by-permission-set]
277
- --permission-sets=<names> Comma-separated list to export (default: all assignable).
337
+ -o, --target-org=<org> (required) Org to read assignments from.
338
+ -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.
339
+ --user=<username>... Only export these users. Repeatable, matched on exact username.
340
+ --kind=<scope>... Only export these scopes: permissionSets | permissionSetGroups | permissionSetLicenses. Repeatable.
341
+ ```
342
+
343
+ 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.
344
+
345
+ 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.
346
+
347
+ ```bash
348
+ # Diff the org's live state against a committed snapshot
349
+ sf ps export -o prod | diff - permissions/prod.yml
350
+
351
+ # Redirect a scoped snapshot to a file of your choosing
352
+ sf ps export -o prod --user jdoe@acme.com > jdoe.yml
353
+ ```
354
+
355
+ 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.
356
+
357
+ ```bash
358
+ # Snapshot one team's permission sets and groups only
359
+ sf ps export -o prod --output-file team.yml \
360
+ --user jdoe@acme.com --user asmith@acme.com \
361
+ --kind permissionSets --kind permissionSetGroups
278
362
  ```
279
363
 
280
- ## CI/CD
364
+ 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.
365
+
366
+ ## GitHub Actions
281
367
 
282
- A typical ladder: lint on every PR, plan against a sandbox, apply on merge:
368
+ Two dead-simple workflows: check pull requests to main with no org, then apply on merge.
369
+
370
+ **1. Check pull requests to main** (no org, no secrets):
283
371
 
284
372
  ```yaml
285
- # .github/workflows/ps-gitops.yml
286
- name: ps-gitops
373
+ # .github/workflows/permissions-check.yml
374
+ name: permissions-check
375
+
287
376
  on:
288
377
  pull_request:
289
- push:
290
378
  branches: [main]
291
379
 
292
380
  jobs:
293
381
  check:
294
382
  runs-on: ubuntu-latest
295
383
  steps:
296
- - uses: actions/checkout@v4
297
- - run: npm install -g @salesforce/cli
298
- - run: sf plugins install sf-plugin-permission-sets
299
- - run: sf ps check --file "permissions/*.yml" --strict
300
-
301
- plan:
302
- if: github.event_name == 'pull_request'
303
- needs: check
304
- runs-on: ubuntu-latest
305
- steps:
306
- - uses: actions/checkout@v4
307
- - run: npm install -g @salesforce/cli
308
- - run: sf plugins install sf-plugin-permission-sets
309
- # Auth via Sfdx auth URL stored in a secrets manager, never hardcode credentials
310
- - run: echo "$SF_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin --alias target
311
- env:
312
- SF_AUTH_URL: ${{ secrets.SF_AUTH_URL }}
313
- - run: sf ps plan -o target --file "permissions/*.yml" --mode sync --fail-on-drift
384
+ - uses: actions/checkout@v7
385
+ - uses: actions/setup-node@v7
386
+ with:
387
+ node-version: 20
388
+
389
+ - name: Install Salesforce CLI
390
+ run: npm install --global @salesforce/cli
391
+
392
+ - name: Install the plugin
393
+ run: sf plugins install sf-plugin-permission-sets
394
+
395
+ - name: Check the permission files
396
+ run: sf ps check --file "permissions/*.yml"
397
+ ```
398
+
399
+ **2. Apply on merge to main** (needs org auth):
400
+
401
+ ```yaml
402
+ # .github/workflows/permissions-apply.yml
403
+ name: permissions-apply
404
+
405
+ on:
406
+ push:
407
+ branches: [main]
314
408
 
409
+ jobs:
315
410
  apply:
316
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
317
- needs: check
318
411
  runs-on: ubuntu-latest
319
412
  steps:
320
- - uses: actions/checkout@v4
321
- - run: npm install -g @salesforce/cli
322
- - run: sf plugins install sf-plugin-permission-sets
323
- - run: echo "$SF_AUTH_URL" | sf org login sfdx-url --sfdx-url-stdin --alias target
324
- env:
325
- SF_AUTH_URL: ${{ secrets.SF_AUTH_URL }}
326
- - run: sf ps apply -o target --file "permissions/*.yml" --mode sync --no-prompt --max-deletes 25
413
+ - uses: actions/checkout@v7
414
+ - uses: actions/setup-node@v7
415
+ with:
416
+ node-version: 20
417
+
418
+ - name: Install Salesforce CLI
419
+ run: npm install --global @salesforce/cli
420
+
421
+ - name: Install the plugin
422
+ run: sf plugins install sf-plugin-permission-sets
423
+
424
+ - name: Log in to the org
425
+ run: echo '${{ secrets.SFDX_AUTH_URL }}' | sf org login sfdx-url --sfdx-url-stdin --alias prod
426
+
427
+ - name: Apply the assignments
428
+ run: sf ps apply --file "permissions/*.yml" --target-org prod --mode sync --no-prompt
327
429
  ```
328
430
 
329
- > **Credentials:** the plugin never reads or stores secrets itself. It uses orgs you've already authenticated with `sf`. In CI, inject auth from your platform's secrets store (as above), not from committed files.
431
+ 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`.
432
+
433
+ 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.
330
434
 
331
435
  ## Inspiration & equivalents
332
436
 
333
- The command surface borrows deliberately from tools you already know:
437
+ This plugin's command surface borrows ideas from tools you already know:
438
+
439
+ - [Terraform](https://developer.hashicorp.com/terraform/docs)
440
+ - [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/)
441
+ - [AWS SAM](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/)
442
+ - [Salesforce CLI](https://developer.salesforce.com/docs/atlas.en-us.sfdx_cli_reference.meta/sfdx_cli_reference/cli_reference.htm)
443
+
444
+ ## Versioning
445
+
446
+ Releases follow [semantic versioning](https://semver.org). Snapshots are automatic, real releases are a manual decision.
447
+
448
+ **Automatic, no action needed:**
449
+
450
+ - Every push to `main` publishes a snapshot `0.0.0-dev.<run>` to the `dev` dist-tag.
451
+ - Creating a release triggers CI to build, stamp the version from the tag, publish it with provenance, and smoke-test the result.
452
+
453
+ **Manual, you decide and trigger:**
454
+
455
+ - Choosing the version bump (patch, minor, or major).
456
+ - Creating the GitHub Release, which is what triggers the publish above.
457
+
458
+ **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`.
459
+
460
+ | Bump | When | Example tag |
461
+ | --- | --- | --- |
462
+ | patch | bug fix, no behavior change | `v0.1.1` |
463
+ | minor | new backward-compatible feature | `v0.2.0` |
464
+ | major | breaking change to a command, flag, or the YAML schema | `v1.0.0` |
465
+
466
+ Cut a release with a tag off `main`:
467
+
468
+ ```bash
469
+ gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
470
+ ```
334
471
 
335
- | This plugin | Terraform | CloudFormation / SAM | sf core |
336
- | -------------------- | ---------------------- | ------------------------------- | -------------------------- |
337
- | `ps check` | `terraform validate` | `sam validate --lint` | n/a |
338
- | `ps validate` | `terraform plan` (refresh) | `cfn validate-template` | `project deploy validate` |
339
- | `ps plan` | `terraform plan` | `cfn create-change-set` | `project deploy preview` |
340
- | `ps apply` | `terraform apply` | `cfn execute-change-set` / `sam deploy` | `project deploy start` |
341
- | `ps export` | `terraform import` | n/a | n/a |
342
- | `--fail-on-drift` | drift in plan exit code | `cfn detect-stack-drift` | n/a |
472
+ | dist-tag | Published by | Install |
473
+ | --- | --- | --- |
474
+ | `latest` | manual release with a normal tag like `v1.2.0` | `sf plugins install sf-plugin-permission-sets` |
475
+ | `dev` | automatic on every push to `main` | `sf plugins install sf-plugin-permission-sets@dev` |
476
+
477
+ ## Architecture
478
+
479
+ 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.
480
+
481
+ - **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.
482
+ - **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.
483
+ - **Core** (`src/core/`): the reusable building blocks. Pure, with no `@salesforce/*` imports, so every piece is unit-testable on its own.
484
+ - **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.
485
+
486
+ | Core module | Responsibility |
487
+ | --- | --- |
488
+ | `model` | Shared domain types (assignment, org, diff). |
489
+ | `finding` | The finding type and code vocabulary, plus constructors, formatting, and counting. |
490
+ | `schema` | The zod contract for a file, plus validation. |
491
+ | `parse` | File text to an object, with YAML and duplicate-key errors. |
492
+ | `normalize` | A validated file to canonical `(assignee, kind, target)` tuples, plus structural findings. |
493
+ | `serialize` | Canonical tuples back to a user-keyed YAML document (the inverse of `normalize`). |
494
+ | `load` | Expand globs, run parse then validate then normalize per file, and merge by union. |
495
+ | `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. |
496
+ | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
497
+ | `mode` | Scope a diff to what a reconcile mode acts on, plus the drift it leaves alone. |
498
+ | `report` | Format a diff as a plan. |
499
+
500
+ 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.
343
501
 
344
502
  ## License
345
503
 
@@ -0,0 +1,21 @@
1
+ import { Connection } from '@salesforce/core';
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
+ /** Adapter backing OrgClient with a Salesforce Connection. autoFetchQuery pages past 2000 rows. */
5
+ export declare class ConnectionOrgClient implements OrgClient {
6
+ private readonly connection;
7
+ constructor(connection: Connection);
8
+ findUsers(usernames: string[]): Promise<OrgUser[]>;
9
+ findTargets(kind: Kind, names: string[]): Promise<OrgTarget[]>;
10
+ listAssignments(filter?: AssignmentFilter): Promise<DesiredAssignment[]>;
11
+ private listMemberships;
12
+ private listLicenses;
13
+ currentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
14
+ private membershipAssignments;
15
+ private licenseAssignments;
16
+ addAssignments(additions: ResolvedAddition[]): Promise<AssignmentOutcome[]>;
17
+ updateAssignments(updates: AssignmentUpdate[]): Promise<AssignmentOutcome[]>;
18
+ removeAssignments(removals: ActualAssignment[]): Promise<AssignmentOutcome[]>;
19
+ private query;
20
+ }
21
+ //# sourceMappingURL=connection-org-client.d.ts.map
@@ -0,0 +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,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;AAsK1D,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;YAyBf,YAAY;IAiBb,kBAAkB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YA4BpE,qBAAqB;YAUrB,kBAAkB;IAWnB,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"}