|
| 1 | +--- |
| 2 | +name: YAML permset assign command |
| 3 | +overview: Add `sf migrate permset assign --file spec.yaml` that parses `PermsetGroups`, an `Upsert` block (create/update FLS grants), and a `Remove` block (revoke FLS and delete rows when both bits clear)—with nested `Objects` / `Fields` lists under each block—then applies the net effect on one or more target orgs using existing Tooling validation, PS queries, and DML patterns. |
| 4 | +todos: |
| 5 | + - id: deps-yaml |
| 6 | + content: Add YAML dependency and wire types/build |
| 7 | + status: completed |
| 8 | + - id: parse-assign-spec |
| 9 | + content: Implement assignSpec parse + PermsetGroups + Upsert/Remove + flatten Objects/Fields + group resolution |
| 10 | + status: completed |
| 11 | + - id: validate-target-only |
| 12 | + content: Add validateAssignMetadata (target-only) reusing tooling helpers |
| 13 | + status: completed |
| 14 | + - id: apply-upsert-remove-dml |
| 15 | + content: Implement applyAssignSpec (Upsert OR-merge, Remove revoke, deletes when row empty) + reuse field DML |
| 16 | + status: pending |
| 17 | + - id: cli-assign |
| 18 | + content: Add migrate permset assign command, messages, schema, README |
| 19 | + status: completed |
| 20 | + - id: tests-snapshot |
| 21 | + content: Unit/command tests, coverage, command-snapshot update |
| 22 | + status: completed |
| 23 | +isProject: false |
| 24 | +--- |
| 25 | + |
| 26 | +# YAML-driven permission assign command |
| 27 | + |
| 28 | +## Behavior |
| 29 | + |
| 30 | +### Top-level sections |
| 31 | + |
| 32 | +- **`PermsetGroups`**: Same as before — `name` (unique) + `permsets` (PermissionSet developer `Name` values; see [`queryPermissionSetsByDeveloperNames`](src/shared/queries.ts)). |
| 33 | + |
| 34 | +- **`Upsert`**: Permissions to **create or update** — expressed as a **sequence of entries**, each entry being either an `Objects` list or a `Fields` list (same nested shape for both `Upsert` and `Remove`): |
| 35 | + |
| 36 | + ```yaml |
| 37 | + Upsert: |
| 38 | + - Objects: |
| 39 | + - name: 'Object' |
| 40 | + fields: |
| 41 | + - name: 'Field' |
| 42 | + perms: [...] |
| 43 | + - Fields: |
| 44 | + - name: 'Object.Field' |
| 45 | + perms: [...] |
| 46 | + ``` |
| 47 | +
|
| 48 | +- **`Remove`**: Permissions to **revoke** — same structural pattern (`Objects` / `Fields` entries in a list, each with `perms` referencing groups). |
| 49 | + |
| 50 | +### Normalization |
| 51 | + |
| 52 | +- Under each of **`Upsert`** and **`Remove`**, normalize `Objects` + `Fields` into a single internal list of `(canonicalField, perms[])`. |
| 53 | +- **`Objects`**: `name` = object API name; each `fields[].name` = field API name → `canonicalField` = `Object.Field`. |
| 54 | +- **`Fields`**: `name` = full `Object.Field` (same convention as [`parsePerms`](src/shared/fields.ts)). |
| 55 | +- Duplicate field definitions across `Objects` vs `Fields` or across multiple entries in the same section: **merge** `perms` (union of operations) within that section before applying section logic. |
| 56 | + |
| 57 | +### `perms` rows |
| 58 | + |
| 59 | +- `{ type: read | edit, permsets: ["Group 1", ...] }` — `permsets` are **group names** referencing `PermsetGroups[].name`. |
| 60 | + |
| 61 | +### Upsert semantics (grants) |
| 62 | + |
| 63 | +- Expand groups → permission sets; for each `(psKey, canonicalField)` in **Upsert**, compute grant flags `grantRead` / `grantEdit` from listed `type`s. |
| 64 | +- Merge with **current** org state: `read' = existing.read || grantRead`, `edit' = existing.edit || grantEdit`. |
| 65 | +- **Edit implies read on grant**: if `grantEdit`, force `read'` true when applying upsert (same as prior plan). |
| 66 | + |
| 67 | +### Remove semantics (revokes) |
| 68 | + |
| 69 | +- For each `(psKey, canonicalField)` in **Remove**, compute revoke flags `revokeRead` / `revokeEdit`. |
| 70 | +- Apply **after** Upsert merge (see below): `read'' = read' && !revokeRead`, `edit'' = edit' && !revokeEdit`. |
| 71 | +- **Read removal implies edit removal**: if `revokeRead`, also set `edit''` false (FLS consistency / API expectations). |
| 72 | +- If `read''` and `edit''` are both false and a `FieldPermissions` row exists → **delete** that row (reuse existing delete path from [`syncPermissions`](src/shared/syncPermissions.ts) / tooling). If at least one bit remains true → **update** the row. |
| 73 | + |
| 74 | +### Conflict between Upsert and Remove (same file) |
| 75 | + |
| 76 | +- **Order of evaluation**: Compute intermediate state from **Upsert** (OR-merge into desired grants), then apply **Remove** subtractively on that result so **Remove wins** for overlapping `(psKey, field)` — i.e. user can both mention a field in Upsert and strip bits in Remove in one run; the remove step has final say. |
| 77 | + |
| 78 | +### Multi-org |
| 79 | + |
| 80 | +- Repeatable `--target-org` (`-t`): same file applied sequentially per org; JSON aggregates per-target results. |
| 81 | + |
| 82 | +### v1 scope |
| 83 | + |
| 84 | +- **Field-level** `read` / `edit` only on `FieldPermissions` (no `ObjectPermissions` in YAML yet). |
| 85 | + |
| 86 | +## Reference spec (full example) |
| 87 | + |
| 88 | +Illustrates `PermsetGroups`, `Upsert` (list of `Objects` / `Fields` entries), and `Remove` with the same shape. Placeholder names (`Object`, `Field`, `set1`, …) should be replaced with real API names and permission set developer names. |
| 89 | + |
| 90 | +```yaml |
| 91 | +PermsetGroups: |
| 92 | + - name: 'Group 1' |
| 93 | + permsets: [set1, set2, setN] |
| 94 | + - name: 'Group 2' |
| 95 | + permsets: [set1, set2, setN] |
| 96 | +
|
| 97 | +# permissions to create or update |
| 98 | +Upsert: |
| 99 | + # objects that we will grant permissions to |
| 100 | + - Objects: |
| 101 | + - name: 'Object' |
| 102 | + fields: |
| 103 | + - name: 'Field' |
| 104 | + perms: |
| 105 | + - type: read |
| 106 | + permsets: ['Group 1', 'Group 2'] |
| 107 | + - type: edit |
| 108 | + permsets: ['Group 1'] |
| 109 | +
|
| 110 | + # fields that we will grant permissions to |
| 111 | + - Fields: |
| 112 | + - name: 'Object.Field' |
| 113 | + perms: |
| 114 | + - type: read |
| 115 | + permsets: ['Group 1', 'Group 2'] |
| 116 | + - type: edit |
| 117 | + permsets: ['Group 1'] |
| 118 | +
|
| 119 | +# permissions to remove |
| 120 | +Remove: |
| 121 | + - Objects: |
| 122 | + - name: 'Object' |
| 123 | + fields: |
| 124 | + - name: 'Field' |
| 125 | + perms: |
| 126 | + - type: read |
| 127 | + permsets: ['Group 1', 'Group 2'] |
| 128 | + - type: edit |
| 129 | + permsets: ['Group 1'] |
| 130 | +
|
| 131 | + - Fields: |
| 132 | + - name: 'Object.Field' |
| 133 | + perms: |
| 134 | + - type: read |
| 135 | + permsets: ['Group 1', 'Group 2'] |
| 136 | + - type: edit |
| 137 | + permsets: ['Group 1'] |
| 138 | +``` |
| 139 | + |
| 140 | +## Architecture |
| 141 | + |
| 142 | +```mermaid |
| 143 | +flowchart LR |
| 144 | + yamlFile[YAML file] |
| 145 | + parse[parseAssignSpec] |
| 146 | + groups[Resolve PermsetGroups] |
| 147 | + up[Flatten Upsert grants] |
| 148 | + rm[Flatten Remove revokes] |
| 149 | + validate[validateAssignMetadata] |
| 150 | + query[Query PS plus existing FLS] |
| 151 | + net[Upsert OR then Remove AND_NOT] |
| 152 | + dml[Insert update delete FieldPermissions] |
| 153 | + yamlFile --> parse --> groups |
| 154 | + parse --> up --> net |
| 155 | + parse --> rm --> net |
| 156 | + groups --> net |
| 157 | + net --> validate --> query --> dml |
| 158 | +``` |
| 159 | + |
| 160 | +## Implementation outline |
| 161 | + |
| 162 | +1. **Dependency**: YAML parser (e.g. npm [`yaml`](https://www.npmjs.com/package/yaml)). |
| 163 | + |
| 164 | +2. **Spec parsing** ([`src/shared/assignSpec.ts`](src/shared/assignSpec.ts) or split): |
| 165 | + |
| 166 | + - Parse `PermsetGroups`, `Upsert`, `Remove`. |
| 167 | + - Validate list-of-`Objects`-or-`Fields` under `Upsert`/`Remove` (each list item is a single-key object: `Objects` or `Fields`). |
| 168 | + - Resolve groups; error on unknown group names; dedupe within Upsert and within Remove. |
| 169 | + - Emit two structures: upsert matrix `(psKey, field) → {grantRead, grantEdit}` and remove matrix `(psKey, field) → {revokeRead, revokeEdit}`. |
| 170 | + |
| 171 | +3. **Target-only metadata validation** ([`src/shared/metadata.ts`](src/shared/metadata.ts)): unchanged intent — all objects/fields referenced in **either** Upsert or Remove (union) must pass Tooling checks (`--strict` vs warn-skip). |
| 172 | + |
| 173 | +4. **Apply engine** ([`src/shared/applyAssignSpec.ts`](src/shared/applyAssignSpec.ts)): |
| 174 | + |
| 175 | + - Query permission sets + existing `FieldPermissions` for all involved `(ParentId, field)` keys. |
| 176 | + - Build **target** map; compute **desired** map: for each key touched by Upsert or Remove, start from existing booleans, apply Upsert OR, then Remove AND-NOT (with read→edit coercion on revoke). |
| 177 | + - Diff desired vs target → `FieldInsert` / `FieldUpdate` / `FieldDelete` (deletes only when both bits false). |
| 178 | + - Reuse exported field DML from [`syncPermissions.ts`](src/shared/syncPermissions.ts) (create/update/delete batches as already implemented). |
| 179 | + |
| 180 | +5. **CLI**: [`src/commands/migrate/permset/assign.ts`](src/commands/migrate/permset/assign.ts) — `--file`, multiple `--target-org`, `--include-profile-owned`, `--strict`, `--api-version`. |
| 181 | + |
| 182 | +6. **Messages**, **JSON schema** for output, **README** (document Upsert vs Remove and conflict order). |
| 183 | + |
| 184 | +7. **Tests**: Parser for list-shaped `Upsert`/`Remove`; merge/revoke math; delete when both false; Upsert+Remove overlap; command snapshot. |
| 185 | + |
| 186 | +## Files likely touched |
| 187 | + |
| 188 | +| Area | Files | |
| 189 | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |
| 190 | +| New | `src/commands/migrate/permset/assign.ts`, `src/shared/assignSpec.ts`, `src/shared/applyAssignSpec.ts`, `messages/migrate.permset.assign.md`, `schemas/migrate-permset-assign.json` | |
| 191 | +| Extend | `src/shared/metadata.ts`, `src/shared/syncPermissions.ts` (export field DML + shared diff helpers if useful) | |
| 192 | +| Docs/tests | `README.md`, `test/shared/*.test.ts`, `test/commands/migrate/permset/assign.test.ts`, `command-snapshot.json`, `package.json` | |
| 193 | + |
| 194 | +## Follow-ups (out of scope) |
| 195 | + |
| 196 | +- Object-level `ObjectPermissions` in YAML. |
| 197 | +- Wildcards (`Object.*`) via [`expandPermWildcards`](src/shared/expandPermWildcards.ts). |
0 commit comments