sf-plugin-permission-sets 0.4.0 → 0.5.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 (63) hide show
  1. package/README.md +75 -41
  2. package/lib/adapters/connection-org-client.d.ts +7 -5
  3. package/lib/adapters/connection-org-client.d.ts.map +1 -1
  4. package/lib/adapters/connection-org-client.js +171 -89
  5. package/lib/adapters/connection-org-client.js.map +1 -1
  6. package/lib/commands/ps/apply.d.ts +1 -15
  7. package/lib/commands/ps/apply.d.ts.map +1 -1
  8. package/lib/commands/ps/apply.js +4 -75
  9. package/lib/commands/ps/apply.js.map +1 -1
  10. package/lib/commands/ps/plan.d.ts +0 -6
  11. package/lib/commands/ps/plan.d.ts.map +1 -1
  12. package/lib/commands/ps/plan.js +2 -36
  13. package/lib/commands/ps/plan.js.map +1 -1
  14. package/lib/core/diff.js +1 -1
  15. package/lib/core/diff.js.map +1 -1
  16. package/lib/core/finding.d.ts.map +1 -1
  17. package/lib/core/finding.js +9 -1
  18. package/lib/core/finding.js.map +1 -1
  19. package/lib/core/index.d.ts +0 -1
  20. package/lib/core/index.d.ts.map +1 -1
  21. package/lib/core/index.js +0 -1
  22. package/lib/core/index.js.map +1 -1
  23. package/lib/core/model.d.ts +8 -8
  24. package/lib/core/model.d.ts.map +1 -1
  25. package/lib/core/normalize.d.ts.map +1 -1
  26. package/lib/core/normalize.js +8 -5
  27. package/lib/core/normalize.js.map +1 -1
  28. package/lib/core/report.d.ts.map +1 -1
  29. package/lib/core/report.js +13 -5
  30. package/lib/core/report.js.map +1 -1
  31. package/lib/core/serialize.d.ts.map +1 -1
  32. package/lib/core/serialize.js +3 -1
  33. package/lib/core/serialize.js.map +1 -1
  34. package/lib/services/adapters/org-client.d.ts +8 -4
  35. package/lib/services/adapters/org-client.d.ts.map +1 -1
  36. package/lib/services/apply.d.ts +1 -12
  37. package/lib/services/apply.d.ts.map +1 -1
  38. package/lib/services/apply.js +1 -27
  39. package/lib/services/apply.js.map +1 -1
  40. package/lib/services/index.d.ts +0 -1
  41. package/lib/services/index.d.ts.map +1 -1
  42. package/lib/services/index.js +0 -1
  43. package/lib/services/index.js.map +1 -1
  44. package/lib/services/plan.d.ts +0 -3
  45. package/lib/services/plan.d.ts.map +1 -1
  46. package/lib/services/plan.js +2 -8
  47. package/lib/services/plan.js.map +1 -1
  48. package/lib/services/resolution.d.ts +2 -1
  49. package/lib/services/resolution.d.ts.map +1 -1
  50. package/lib/services/resolution.js +12 -1
  51. package/lib/services/resolution.js.map +1 -1
  52. package/lib/services/validate.d.ts +2 -1
  53. package/lib/services/validate.d.ts.map +1 -1
  54. package/lib/services/validate.js +10 -1
  55. package/lib/services/validate.js.map +1 -1
  56. package/messages/ps.apply.md +2 -34
  57. package/messages/ps.plan.md +0 -8
  58. package/messages/ps.validate.md +1 -1
  59. package/package.json +8 -4
  60. package/lib/core/plan-file.d.ts +0 -61
  61. package/lib/core/plan-file.d.ts.map +0 -1
  62. package/lib/core/plan-file.js +0 -74
  63. package/lib/core/plan-file.js.map +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
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) [![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)
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.md)
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.
@@ -25,6 +25,7 @@ Stop clicking through Setup to grant access. Commit a YAML file, open a PR, let
25
25
  - [Inspiration & equivalents](#inspiration--equivalents)
26
26
  - [Versioning](#versioning)
27
27
  - [Architecture](#architecture)
28
+ - [Development](#development)
28
29
 
29
30
  ---
30
31
 
@@ -60,22 +61,22 @@ Requires Salesforce CLI (`sf`) and Node.js 20+.
60
61
 
61
62
  ```bash
62
63
  # 1. Bootstrap YAML from an existing org (so you don't start from scratch)
63
- sf ps export --target-org dev --output-file permissions.yml
64
+ sf ps export --target-org dev --output-file permissions/dev.yml
64
65
 
65
66
  # 2. Edit the files, commit, open a PR. Check them, no org needed:
66
- sf ps check --file "./permissions/*.yml"
67
+ sf ps check --file "permissions/*.yml"
67
68
 
68
69
  # 3. Validate against a real org (do the users/permission sets exist?)
69
- sf ps validate --file "./permissions/*.yml" --target-org dev
70
+ sf ps validate --file "permissions/*.yml" --target-org dev
70
71
 
71
72
  # 4. See what would change
72
- sf ps plan --file "./permissions/*.yml" --target-org dev
73
+ sf ps plan --file "permissions/*.yml" --target-org dev
73
74
 
74
75
  # 5. Apply it (additive by default, only adds)
75
- sf ps apply --file "./permissions/*.yml" --target-org dev
76
+ sf ps apply --file "permissions/*.yml" --target-org dev
76
77
 
77
78
  # 6. Full reconcile, including removals (opt-in)
78
- sf ps apply --file "./permissions/*.yml" --target-org prod --mode sync
79
+ sf ps apply --file "permissions/*.yml" --target-org prod --mode sync
79
80
  ```
80
81
 
81
82
  ## Permission files
@@ -250,19 +251,15 @@ group, and license referenced actually exists and resolves uniquely.
250
251
 
251
252
  ```
252
253
  USAGE
253
- $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged]
254
- [--out <file>] [--json]
254
+ $ sf ps plan -o <org> -f <glob>... [--mode <value>] [--show-unchanged] [--json]
255
255
 
256
256
  FLAGS
257
257
  -o, --target-org=<org> (required)
258
258
  -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
259
259
  --mode=<value> additive | destructive | sync [default: additive]
260
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.
262
261
  ```
263
262
 
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
263
  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:
267
264
 
268
265
  ```text
@@ -311,13 +308,12 @@ Next: sf ps apply -o prod -f "permissions/*.yml" --mode sync
311
308
 
312
309
  ```
313
310
  USAGE
314
- $ sf ps apply -o <org> (-f <glob>... | --plan <file>) [--mode <value>]
311
+ $ sf ps apply -o <org> -f <glob>... [--mode <value>]
315
312
  [--max-deletes <n>] [--dry-run] [--show-unchanged] [--no-prompt] [--json]
316
313
 
317
314
  FLAGS
318
315
  -o, --target-org=<org> (required)
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.
316
+ -f, --file=<glob>... (required) YAML file(s) to read. Repeatable, globs expanded by the plugin.
321
317
  --mode=<value> additive | destructive | sync [default: additive]
322
318
  --max-deletes=<n> Abort if a run would remove more than n assignments. [default: 50]
323
319
  --dry-run Resolve and diff, print what would happen, change nothing.
@@ -325,28 +321,10 @@ FLAGS
325
321
  --no-prompt Skip the deletion confirmation prompt (for CI).
326
322
  ```
327
323
 
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.
324
+ `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).
329
325
 
330
326
  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.
331
327
 
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
-
350
328
  ### `sf ps export`
351
329
 
352
330
  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.
@@ -388,7 +366,7 @@ A requested `--user` that has no matching assignments (a typo, or a user who gen
388
366
 
389
367
  ## GitHub Actions
390
368
 
391
- Two dead-simple workflows: check pull requests to main with no org, then apply on merge.
369
+ Three small workflows that build on each other: check pull requests with no org at all, validate the same files against the real org, then apply on merge.
392
370
 
393
371
  **1. Check pull requests to main** (no org, no secrets):
394
372
 
@@ -419,7 +397,41 @@ jobs:
419
397
  run: sf ps check --file "permissions/*.yml"
420
398
  ```
421
399
 
422
- **2. Apply on merge to main** (needs org auth):
400
+ **2. Validate pull requests against the org** (needs org auth):
401
+
402
+ ```yaml
403
+ # .github/workflows/permissions-validate.yml
404
+ name: permissions-validate
405
+
406
+ on:
407
+ pull_request:
408
+ branches: [main]
409
+ paths:
410
+ - "permissions/**"
411
+
412
+ jobs:
413
+ validate:
414
+ runs-on: ubuntu-latest
415
+ steps:
416
+ - uses: actions/checkout@v7
417
+ - uses: actions/setup-node@v7
418
+ with:
419
+ node-version: 20
420
+
421
+ - name: Install Salesforce CLI
422
+ run: npm install --global @salesforce/cli
423
+
424
+ - name: Install the plugin
425
+ run: sf plugins install sf-plugin-permission-sets
426
+
427
+ - name: Log in to the org
428
+ run: echo '${{ secrets.SFDX_AUTH_URL }}' | sf org login sfdx-url --sfdx-url-stdin --alias prod
429
+
430
+ - name: Validate the permission files
431
+ run: sf ps validate --file "permissions/*.yml" --target-org prod
432
+ ```
433
+
434
+ **3. Apply on merge to main** (needs org auth):
423
435
 
424
436
  ```yaml
425
437
  # .github/workflows/permissions-apply.yml
@@ -451,9 +463,9 @@ jobs:
451
463
  run: sf ps apply --file "permissions/*.yml" --target-org prod --mode sync --no-prompt
452
464
  ```
453
465
 
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`.
466
+ Workflows 2 and 3 share one secret. 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
467
 
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.
468
+ Want the diff on the PR before merging? Add a `sf ps plan --file "permissions/*.yml" --target-org prod` step to workflow 2, right after the login step, so reviewers see the change set that `apply` will carry out on merge.
457
469
 
458
470
  ## Inspiration & equivalents
459
471
 
@@ -502,7 +514,7 @@ gh release create v0.2.0 --target main --title v0.2.0 --notes "Add ps export"
502
514
  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.
503
515
 
504
516
  - **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.
505
- - **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.
517
+ - **Services** (`src/services/`): one per command (`check`, `validate`, `export`, `apply`, and `plan`), plus `resolution`, which the org-facing ones share. Each is a class whose constructor takes only its dependencies (the org client, a confirmation callback), while the per-invocation inputs are `run()` parameters, so one instance serves any number of runs. A service also declares the ports it needs from the outside, like the `OrgClient` interface its adapter implements.
506
518
  - **Core** (`src/core/`): the reusable building blocks. Pure, with no `@salesforce/*` imports, so every piece is unit-testable on its own.
507
519
  - **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.
508
520
 
@@ -519,9 +531,31 @@ The plugin is layered so every command reuses the same core. Commands stay thin,
519
531
  | `diff` | The desired model vs. the org's current state, producing adds, removes, and unchanged. |
520
532
  | `mode` | Scope a diff to what a reconcile mode acts on, plus the drift it leaves alone. |
521
533
  | `report` | Format a diff as a plan. |
522
- | `plan-file` | Serialize and parse a saved plan: the frozen change set `apply --plan` runs. |
523
534
 
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.
535
+ 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.
536
+
537
+ ## Development
538
+
539
+ ```bash
540
+ npm ci
541
+ npm run build # compile and lint
542
+ npm test # compile, then run the suite
543
+ ```
544
+
545
+ The suite is black-box: every spec spawns the real `sf ps ...` binary. Install the Salesforce CLI first (`npm install -g @salesforce/cli`). The test run links this plugin into `sf` before the first spec and unlinks it at the end.
546
+
547
+ ### Test environment
548
+
549
+ Tests take their parameters from the environment, never from a committed file. Copy the template and fill it in:
550
+
551
+ ```bash
552
+ cp .env.example .env
553
+ ```
554
+
555
+ | Variable | Required | What it is |
556
+ | --- | --- | --- |
557
+ | `PS_TARGET_ORG` | yes | Username or alias of an already-authenticated org. The `plan`, `apply`, and `export` specs run against it. |
558
+
525
559
 
526
560
  ## License
527
561
 
@@ -1,18 +1,20 @@
1
1
  import { Connection } from '@salesforce/core';
2
- import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, Kind, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/index.js';
2
+ import { ActualAssignment, AssignmentFilter, AssignmentOutcome, AssignmentUpdate, DesiredAssignment, OrgTarget, OrgUser, ResolvedAddition, TargetRef } from '../core/index.js';
3
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
- findTargets(kind: Kind, names: string[]): Promise<OrgTarget[]>;
9
+ findPermissionSets(names: string[]): Promise<OrgTarget[]>;
10
+ findPermissionSetGroups(names: string[]): Promise<OrgTarget[]>;
11
+ findPermissionSetLicenses(names: string[]): Promise<OrgTarget[]>;
10
12
  listAssignments(filter?: AssignmentFilter): Promise<DesiredAssignment[]>;
11
13
  private listMemberships;
12
14
  private listLicenses;
13
- currentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
14
- private membershipAssignments;
15
- private licenseAssignments;
15
+ listCurrentAssignments(targets: TargetRef[]): Promise<ActualAssignment[]>;
16
+ private listMembershipAssignments;
17
+ private listLicenseAssignments;
16
18
  addAssignments(additions: ResolvedAddition[]): Promise<AssignmentOutcome[]>;
17
19
  updateAssignments(updates: AssignmentUpdate[]): Promise<AssignmentOutcome[]>;
18
20
  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,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"}
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,EAEjB,SAAS,EACT,OAAO,EACP,gBAAgB,EAChB,SAAS,EACZ,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAgR1D,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;IAMlD,kBAAkB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMzD,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAM9D,yBAAyB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAMhE,eAAe,CAAC,MAAM,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;YAkBvE,eAAe;YAoBf,YAAY;IAYb,sBAAsB,CAAC,OAAO,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAqBxE,yBAAyB;YAezB,sBAAsB;IAWvB,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"}
@@ -1,9 +1,3 @@
1
- /** SObject + naming field per kind. The Salesforce schema knowledge lives here, not in core. */
2
- const targetObjects = {
3
- permissionSet: { sobject: 'PermissionSet', field: 'Name' },
4
- permissionSetGroup: { sobject: 'PermissionSetGroup', field: 'DeveloperName' },
5
- permissionSetLicense: { sobject: 'PermissionSetLicense', field: 'DeveloperName' },
6
- };
7
1
  /** SObject + foreign-key field to set per kind when assigning. */
8
2
  const assignmentObjects = {
9
3
  permissionSet: { sobject: 'PermissionSetAssignment', idField: 'PermissionSetId' },
@@ -13,12 +7,121 @@ const assignmentObjects = {
13
7
  /** The sObject Collections API caps each create/delete call at 200 records. */
14
8
  const collectionBatchSize = 200;
15
9
  /** Escape a value for safe inclusion in a SOQL string literal. */
16
- function soqlLiteral(value) {
10
+ function escapeSoqlLiteral(value) {
17
11
  return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
18
12
  }
19
13
  /** Build a comma-separated, quoted IN list from the values. */
20
- function inList(values) {
21
- return values.map((value) => `'${soqlLiteral(value)}'`).join(', ');
14
+ function buildInList(values) {
15
+ return values.map((value) => `'${escapeSoqlLiteral(value)}'`).join(', ');
16
+ }
17
+ /** Full membership SOQL for the active, non-profile-owned assignments matching the filter. */
18
+ function buildMembershipQuery(usernames, wantsPermissionSet, wantsGroup) {
19
+ const clauses = [
20
+ 'Assignee.IsActive = true',
21
+ 'PermissionSet.IsOwnedByProfile = false',
22
+ ];
23
+ if (usernames)
24
+ clauses.push(`Assignee.Username IN(${buildInList(usernames)})`);
25
+ if (!wantsGroup)
26
+ clauses.push('PermissionSetGroupId = null');
27
+ if (!wantsPermissionSet)
28
+ clauses.push('PermissionSetGroupId != null');
29
+ return `
30
+ SELECT
31
+ Id,
32
+ Assignee.Username,
33
+ PermissionSet.Name,
34
+ PermissionSetGroup.DeveloperName,
35
+ PermissionSetGroupId,
36
+ ExpirationDate
37
+ FROM PermissionSetAssignment
38
+ WHERE ${clauses.join(' AND ')}
39
+ `;
40
+ }
41
+ /** Full membership SOQL for the current assignments of the given permission set and group ids. */
42
+ function buildCurrentMembershipQuery(permissionSetIds, groupIds) {
43
+ const clauses = [];
44
+ if (permissionSetIds.length > 0)
45
+ clauses.push(`PermissionSetId IN(${buildInList(permissionSetIds)})`);
46
+ if (groupIds.length > 0)
47
+ clauses.push(`PermissionSetGroupId IN(${buildInList(groupIds)})`);
48
+ return `
49
+ SELECT
50
+ Id,
51
+ Assignee.Username,
52
+ PermissionSet.Name,
53
+ PermissionSetGroup.DeveloperName,
54
+ PermissionSetGroupId,
55
+ ExpirationDate
56
+ FROM PermissionSetAssignment
57
+ WHERE ${clauses.join(' OR ')}
58
+ `;
59
+ }
60
+ /** Full license SOQL for the active assignments matching the filter. */
61
+ function buildLicenseQuery(usernames) {
62
+ const clauses = ['Assignee.IsActive = true'];
63
+ if (usernames)
64
+ clauses.push(`Assignee.Username IN(${buildInList(usernames)})`);
65
+ return `
66
+ SELECT
67
+ Id,
68
+ Assignee.Username,
69
+ PermissionSetLicense.DeveloperName
70
+ FROM PermissionSetLicenseAssign
71
+ WHERE ${clauses.join(' AND ')}
72
+ `;
73
+ }
74
+ /** Full license SOQL for the current assignments of the given license ids. */
75
+ function buildCurrentLicenseQuery(licenseIds) {
76
+ return `
77
+ SELECT
78
+ Id,
79
+ Assignee.Username,
80
+ PermissionSetLicense.DeveloperName
81
+ FROM PermissionSetLicenseAssign
82
+ WHERE PermissionSetLicenseId IN(${buildInList(licenseIds)})
83
+ `;
84
+ }
85
+ /** Full SOQL for the users with the given usernames. */
86
+ function buildUserQuery(usernames) {
87
+ return `
88
+ SELECT
89
+ Id,
90
+ Username,
91
+ IsActive
92
+ FROM User
93
+ WHERE Username IN(${buildInList(usernames)})
94
+ `;
95
+ }
96
+ /** Full SOQL for the permission sets with the given names. */
97
+ function buildPermissionSetQuery(names) {
98
+ return `
99
+ SELECT
100
+ Id,
101
+ Name
102
+ FROM PermissionSet
103
+ WHERE Name IN(${buildInList(names)})
104
+ `;
105
+ }
106
+ /** Full SOQL for the permission set groups with the given developer names. */
107
+ function buildPermissionSetGroupQuery(names) {
108
+ return `
109
+ SELECT
110
+ Id,
111
+ DeveloperName
112
+ FROM PermissionSetGroup
113
+ WHERE DeveloperName IN(${buildInList(names)})
114
+ `;
115
+ }
116
+ /** Full SOQL for the permission set licenses with the given developer names. */
117
+ function buildPermissionSetLicenseQuery(names) {
118
+ return `
119
+ SELECT
120
+ Id,
121
+ DeveloperName
122
+ FROM PermissionSetLicense
123
+ WHERE DeveloperName IN(${buildInList(names)})
124
+ `;
22
125
  }
23
126
  /** Split items into chunks of at most `size`. */
24
127
  function chunk(items, size) {
@@ -29,7 +132,7 @@ function chunk(items, size) {
29
132
  return chunks;
30
133
  }
31
134
  /** Turn a per-record DML result into a domain outcome, capturing the error message on failure. */
32
- function outcomeOf(assignment, operation, result) {
135
+ function deriveOutcome(assignment, operation, result) {
33
136
  const success = result?.success ?? false;
34
137
  const message = result && !result.success ? result.errors.map((error) => error.message).join('; ') : undefined;
35
138
  return {
@@ -42,7 +145,7 @@ function outcomeOf(assignment, operation, result) {
42
145
  };
43
146
  }
44
147
  /** Group additions by sObject and chunk them for the Collections API, keeping each record's source. */
45
- function additionBatches(additions) {
148
+ function buildAdditionBatches(additions) {
46
149
  const bySobject = new Map();
47
150
  for (const addition of additions) {
48
151
  const { sobject } = assignmentObjects[addition.kind];
@@ -64,7 +167,7 @@ function additionBatches(additions) {
64
167
  return batches;
65
168
  }
66
169
  /** Group expiration updates by sObject and chunk them, building the Id + ExpirationDate records. */
67
- function updateBatches(updates) {
170
+ function buildUpdateBatches(updates) {
68
171
  const bySobject = new Map();
69
172
  for (const update of updates) {
70
173
  const { sobject } = assignmentObjects[update.kind];
@@ -85,7 +188,7 @@ function updateBatches(updates) {
85
188
  return batches;
86
189
  }
87
190
  /** Group removals by sObject and chunk them for the Collections API. */
88
- function removalBatches(removals) {
191
+ function buildRemovalBatches(removals) {
89
192
  const bySobject = new Map();
90
193
  for (const removal of removals) {
91
194
  const { sobject } = assignmentObjects[removal.kind];
@@ -101,6 +204,13 @@ function removalBatches(removals) {
101
204
  }
102
205
  return batches;
103
206
  }
207
+ /** Classify a membership row: a group grant when it carries a group id, otherwise a plain permission set. */
208
+ function classifyMembership(record) {
209
+ if (record.PermissionSetGroupId && record.PermissionSetGroup) {
210
+ return { kind: 'permissionSetGroup', target: record.PermissionSetGroup.DeveloperName };
211
+ }
212
+ return { kind: 'permissionSet', target: record.PermissionSet.Name };
213
+ }
104
214
  /** Adapter backing OrgClient with a Salesforce Connection. autoFetchQuery pages past 2000 rows. */
105
215
  export class ConnectionOrgClient {
106
216
  connection;
@@ -108,13 +218,20 @@ export class ConnectionOrgClient {
108
218
  this.connection = connection;
109
219
  }
110
220
  async findUsers(usernames) {
111
- const records = await this.query(`SELECT Id, Username, IsActive FROM User WHERE Username IN (${inList(usernames)})`);
221
+ const records = await this.query(buildUserQuery(usernames));
112
222
  return records.map((record) => ({ id: record.Id, username: record.Username, isActive: record.IsActive }));
113
223
  }
114
- async findTargets(kind, names) {
115
- const { sobject, field } = targetObjects[kind];
116
- const records = await this.query(`SELECT Id, ${field} FROM ${sobject} WHERE ${field} IN (${inList(names)})`);
117
- return records.map((record) => ({ id: record.Id, name: record[field] }));
224
+ async findPermissionSets(names) {
225
+ const records = await this.query(buildPermissionSetQuery(names));
226
+ return records.map((record) => ({ id: record.Id, name: record.Name }));
227
+ }
228
+ async findPermissionSetGroups(names) {
229
+ const records = await this.query(buildPermissionSetGroupQuery(names));
230
+ return records.map((record) => ({ id: record.Id, name: record.DeveloperName }));
231
+ }
232
+ async findPermissionSetLicenses(names) {
233
+ const records = await this.query(buildPermissionSetLicenseQuery(names));
234
+ return records.map((record) => ({ id: record.Id, name: record.DeveloperName }));
118
235
  }
119
236
  async listAssignments(filter) {
120
237
  const kinds = filter?.kinds;
@@ -132,141 +249,106 @@ export class ConnectionOrgClient {
132
249
  return results.flat();
133
250
  }
134
251
  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 ')}`;
252
+ const soql = buildMembershipQuery(usernames, wantsPermissionSet, wantsGroup);
147
253
  const records = await this.query(soql);
148
254
  return records.map((record) => {
149
- const expiration = record.ExpirationDate ? { expiration: record.ExpirationDate } : {};
150
- return record.PermissionSetGroupId && record.PermissionSetGroup
151
- ? {
152
- assignee: record.Assignee.Username,
153
- kind: 'permissionSetGroup',
154
- target: record.PermissionSetGroup.DeveloperName,
155
- ...expiration,
156
- }
157
- : {
158
- assignee: record.Assignee.Username,
159
- kind: 'permissionSet',
160
- target: record.PermissionSet.Name,
161
- ...expiration,
162
- };
255
+ const { kind, target } = classifyMembership(record);
256
+ return {
257
+ kind,
258
+ target,
259
+ assignee: record.Assignee.Username,
260
+ expiration: record.ExpirationDate,
261
+ };
163
262
  });
164
263
  }
165
264
  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 ')}`;
265
+ const soql = buildLicenseQuery(usernames);
171
266
  const records = await this.query(soql);
172
267
  return records.map((record) => ({
173
268
  assignee: record.Assignee.Username,
174
269
  kind: 'permissionSetLicense',
175
270
  target: record.PermissionSetLicense.DeveloperName,
271
+ expiration: null,
176
272
  }));
177
273
  }
178
- async currentAssignments(targets) {
274
+ async listCurrentAssignments(targets) {
179
275
  const permissionSetIds = targets.filter((ref) => ref.kind === 'permissionSet').map((ref) => ref.id);
180
276
  const groupIds = targets.filter((ref) => ref.kind === 'permissionSetGroup').map((ref) => ref.id);
181
277
  const licenseIds = targets.filter((ref) => ref.kind === 'permissionSetLicense').map((ref) => ref.id);
182
278
  const tasks = [];
183
- const memberClauses = [];
184
- if (permissionSetIds.length > 0)
185
- memberClauses.push(`PermissionSetId IN (${inList(permissionSetIds)})`);
186
- if (groupIds.length > 0)
187
- memberClauses.push(`PermissionSetGroupId IN (${inList(groupIds)})`);
188
- if (memberClauses.length > 0) {
189
- const soql = 'SELECT Id, Assignee.Username, PermissionSet.Name, PermissionSetGroup.DeveloperName, PermissionSetGroupId, ExpirationDate ' +
190
- `FROM PermissionSetAssignment WHERE ${memberClauses.join(' OR ')}`;
191
- tasks.push(this.membershipAssignments(soql));
279
+ if (permissionSetIds.length > 0 || groupIds.length > 0) {
280
+ const soql = buildCurrentMembershipQuery(permissionSetIds, groupIds);
281
+ tasks.push(this.listMembershipAssignments(soql));
192
282
  }
193
283
  if (licenseIds.length > 0) {
194
- const soql = 'SELECT Id, Assignee.Username, PermissionSetLicense.DeveloperName ' +
195
- `FROM PermissionSetLicenseAssign WHERE PermissionSetLicenseId IN (${inList(licenseIds)})`;
196
- tasks.push(this.licenseAssignments(soql));
284
+ const soql = buildCurrentLicenseQuery(licenseIds);
285
+ tasks.push(this.listLicenseAssignments(soql));
197
286
  }
198
287
  const results = await Promise.all(tasks);
199
288
  return results.flat();
200
289
  }
201
- async membershipAssignments(soql) {
290
+ async listMembershipAssignments(soql) {
202
291
  const records = await this.query(soql);
203
292
  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
- };
293
+ const { kind, target } = classifyMembership(record);
294
+ return {
295
+ kind,
296
+ target,
297
+ recordId: record.Id,
298
+ assignee: record.Assignee.Username,
299
+ expiration: record.ExpirationDate,
300
+ };
220
301
  });
221
302
  }
222
- async licenseAssignments(soql) {
303
+ async listLicenseAssignments(soql) {
223
304
  const records = await this.query(soql);
224
305
  return records.map((record) => ({
225
306
  recordId: record.Id,
226
307
  assignee: record.Assignee.Username,
227
308
  kind: 'permissionSetLicense',
228
309
  target: record.PermissionSetLicense.DeveloperName,
310
+ expiration: null,
229
311
  }));
230
312
  }
231
313
  async addAssignments(additions) {
232
- const batches = additionBatches(additions);
314
+ const batches = buildAdditionBatches(additions);
233
315
  const settled = await Promise.all(batches.map(async (batch) => {
234
316
  const results = await this.connection.create(batch.sobject, batch.records, { allOrNone: false });
235
- return { batch, results: results };
317
+ return { batch, results };
236
318
  }));
237
319
  const outcomes = [];
238
320
  for (const { batch, results } of settled) {
239
321
  batch.additions.forEach((addition, index) => {
240
- outcomes.push(outcomeOf(addition, 'add', results[index]));
322
+ outcomes.push(deriveOutcome(addition, 'add', results[index]));
241
323
  });
242
324
  }
243
325
  return outcomes;
244
326
  }
245
327
  async updateAssignments(updates) {
246
- const batches = updateBatches(updates);
328
+ const batches = buildUpdateBatches(updates);
247
329
  const settled = await Promise.all(batches.map(async (batch) => {
248
330
  const results = await this.connection.update(batch.sobject, batch.records, { allOrNone: false });
249
- return { batch, results: results };
331
+ return { batch, results };
250
332
  }));
251
333
  const outcomes = [];
252
334
  for (const { batch, results } of settled) {
253
335
  batch.updates.forEach((update, index) => {
254
- outcomes.push(outcomeOf(update, 'update', results[index]));
336
+ outcomes.push(deriveOutcome(update, 'update', results[index]));
255
337
  });
256
338
  }
257
339
  return outcomes;
258
340
  }
259
341
  async removeAssignments(removals) {
260
- const batches = removalBatches(removals);
342
+ const batches = buildRemovalBatches(removals);
261
343
  const settled = await Promise.all(batches.map(async (batch) => {
262
344
  const recordIds = batch.removals.map((removal) => removal.recordId);
263
345
  const results = await this.connection.destroy(batch.sobject, recordIds, { allOrNone: false });
264
- return { batch, results: results };
346
+ return { batch, results };
265
347
  }));
266
348
  const outcomes = [];
267
349
  for (const { batch, results } of settled) {
268
350
  batch.removals.forEach((removal, index) => {
269
- outcomes.push(outcomeOf(removal, 'remove', results[index]));
351
+ outcomes.push(deriveOutcome(removal, 'remove', results[index]));
270
352
  });
271
353
  }
272
354
  return outcomes;