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.
- package/README.md +75 -41
- package/lib/adapters/connection-org-client.d.ts +7 -5
- package/lib/adapters/connection-org-client.d.ts.map +1 -1
- package/lib/adapters/connection-org-client.js +171 -89
- package/lib/adapters/connection-org-client.js.map +1 -1
- package/lib/commands/ps/apply.d.ts +1 -15
- package/lib/commands/ps/apply.d.ts.map +1 -1
- package/lib/commands/ps/apply.js +4 -75
- package/lib/commands/ps/apply.js.map +1 -1
- package/lib/commands/ps/plan.d.ts +0 -6
- package/lib/commands/ps/plan.d.ts.map +1 -1
- package/lib/commands/ps/plan.js +2 -36
- package/lib/commands/ps/plan.js.map +1 -1
- package/lib/core/diff.js +1 -1
- package/lib/core/diff.js.map +1 -1
- package/lib/core/finding.d.ts.map +1 -1
- package/lib/core/finding.js +9 -1
- package/lib/core/finding.js.map +1 -1
- package/lib/core/index.d.ts +0 -1
- package/lib/core/index.d.ts.map +1 -1
- package/lib/core/index.js +0 -1
- package/lib/core/index.js.map +1 -1
- package/lib/core/model.d.ts +8 -8
- package/lib/core/model.d.ts.map +1 -1
- package/lib/core/normalize.d.ts.map +1 -1
- package/lib/core/normalize.js +8 -5
- package/lib/core/normalize.js.map +1 -1
- package/lib/core/report.d.ts.map +1 -1
- package/lib/core/report.js +13 -5
- package/lib/core/report.js.map +1 -1
- package/lib/core/serialize.d.ts.map +1 -1
- package/lib/core/serialize.js +3 -1
- package/lib/core/serialize.js.map +1 -1
- package/lib/services/adapters/org-client.d.ts +8 -4
- package/lib/services/adapters/org-client.d.ts.map +1 -1
- package/lib/services/apply.d.ts +1 -12
- package/lib/services/apply.d.ts.map +1 -1
- package/lib/services/apply.js +1 -27
- package/lib/services/apply.js.map +1 -1
- package/lib/services/index.d.ts +0 -1
- package/lib/services/index.d.ts.map +1 -1
- package/lib/services/index.js +0 -1
- package/lib/services/index.js.map +1 -1
- package/lib/services/plan.d.ts +0 -3
- package/lib/services/plan.d.ts.map +1 -1
- package/lib/services/plan.js +2 -8
- package/lib/services/plan.js.map +1 -1
- package/lib/services/resolution.d.ts +2 -1
- package/lib/services/resolution.d.ts.map +1 -1
- package/lib/services/resolution.js +12 -1
- package/lib/services/resolution.js.map +1 -1
- package/lib/services/validate.d.ts +2 -1
- package/lib/services/validate.d.ts.map +1 -1
- package/lib/services/validate.js +10 -1
- package/lib/services/validate.js.map +1 -1
- package/messages/ps.apply.md +2 -34
- package/messages/ps.plan.md +0 -8
- package/messages/ps.validate.md +1 -1
- package/package.json +8 -4
- package/lib/core/plan-file.d.ts +0 -61
- package/lib/core/plan-file.d.ts.map +0 -1
- package/lib/core/plan-file.js +0 -74
- 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
|
-
[](https://www.npmjs.com/package/sf-plugin-permission-sets) [](https://npmjs.org/package/sf-plugin-permission-sets) [](https://semver.org/#spec-item-4) [](https://raw.githubusercontent.com/zaclummys/sf-plugin-permission-sets/main/LICENSE.
|
|
3
|
+
[](https://www.npmjs.com/package/sf-plugin-permission-sets) [](https://npmjs.org/package/sf-plugin-permission-sets) [](https://semver.org/#spec-item-4) [](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 "
|
|
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 "
|
|
70
|
+
sf ps validate --file "permissions/*.yml" --target-org dev
|
|
70
71
|
|
|
71
72
|
# 4. See what would change
|
|
72
|
-
sf ps plan --file "
|
|
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 "
|
|
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 "
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
private
|
|
15
|
-
private
|
|
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,
|
|
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
|
|
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
|
|
21
|
-
return values.map((value) => `'${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
150
|
-
return
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 =
|
|
195
|
-
|
|
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
|
|
290
|
+
async listMembershipAssignments(soql) {
|
|
202
291
|
const records = await this.query(soql);
|
|
203
292
|
return records.map((record) => {
|
|
204
|
-
const
|
|
205
|
-
return
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
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(
|
|
351
|
+
outcomes.push(deriveOutcome(removal, 'remove', results[index]));
|
|
270
352
|
});
|
|
271
353
|
}
|
|
272
354
|
return outcomes;
|