Skip to content

Commit d6283ba

Browse files
committed
feat: add YAML-based permset assign command and schema
1 parent d8d0df2 commit d6283ba

20 files changed

Lines changed: 1762 additions & 124 deletions
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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

Comments
 (0)