Skip to content

Commit d8d0df2

Browse files
committed
feat: enhance permset migration with wildcard support and strict validation
- Added support for wildcard expansion in permission specifications (e.g., `Object.*`). - Introduced a `--strict` flag to enforce validation of target fields and objects during migration. - Updated metadata validation to skip or throw errors based on the strictness setting. - Improved documentation in README and message files to reflect new features and usage examples. - Added tests for new functionality, including wildcard expansion and strict validation scenarios.
1 parent ab2b59d commit d8d0df2

15 files changed

Lines changed: 663 additions & 81 deletions

File tree

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,13 @@ _See code: [src/commands/migrate/permset/copy.ts](https://github.com/wisefox/plu
172172
- Permission sets are matched across orgs by **API name** (`PermissionSet.Name`) and **NamespacePrefix**. If the same API name exists in different namespaces, resolve collisions by ensuring metadata is unambiguous on both sides.
173173
- Only permission sets that already exist on the **target** are updated. Source-only permission sets are reported in JSON output under `skippedPermissionSets` and are not created on the target.
174174
- **Object-only** tokens (e.g. `Account`) sync **ObjectPermissions** for that object for **every matched** permission set on both orgs. **Field** tokens sync **FieldPermissions** only for that field; if you do **not** pass any object-only token, object-level rows for that object are synced only for permission sets that have source field rows. If you pass **both** (e.g. `Account,Account.Name`), object-level sync covers all matched sets for the objects in your `--perms` list, and the source query includes **ObjectPermissions** for every matched permission set so rows are not missed when discovery omitted a parent.
175-
- DML order on the target is: insert **ObjectPermissions** then **FieldPermissions**, then updates in the same order, then delete **FieldPermissions** then **ObjectPermissions**, so object rows exist before field rows and revokes remove field rows before object rows.
175+
- DML order on the target is: insert **ObjectPermissions** then **FieldPermissions**, then updates in the same order, then delete **FieldPermissions** then **ObjectPermissions**, so object rows exist before field rows and revokes remove field rows before object rows. Field/Object permission **deletes** use one REST **DELETE per record Id** (not the composite `?ids=` batch) for compatibility with Salesforce responses. Query rows normalize `Id` / `id` from the API.
176+
- **`Object.*` (or `Object:*`)** expands to fields on that object where Tooling **FieldDefinition** reports **IsFlsEnabled** (same idea as “permissionable” in describe). The field list is resolved on the **source** org via the Tooling API (metadata, not the user’s FLS visibility). By default, fields (or whole objects) that are missing on the **target** are **skipped** with a warning; use **`--strict`** to fail instead if anything in `--perms` is absent on the target.
177+
- Schema checks use the **Tooling API** (`FieldDefinition`, `EntityDefinition`). The integration user needs access to Tooling (e.g. **View Setup and Configuration** or equivalent), not only FLS on the fields being migrated. `FieldDefinition` SOQL filters use the `EntityDefinition` relationship in the `WHERE` clause (not only `FROM FieldDefinition`).
178+
- **Field sync** only considers `FieldPermissions` rows whose **`SobjectType`** matches an object from `--perms` (e.g. `Contact.*` ignores unrelated rows such as other entities that can appear in broad queries). That avoids diffing or deleting FLS for objects you did not request.
176179

177180
### Salesforce API references
178181

179182
- [FieldPermissions](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_fieldpermissions.htm)
180-
- [ObjectPermissions](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_objectpermissions.htm)
183+
- [ObjectPermissions](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_objectpermissions.htm) — includes `PermissionsViewAllFields` (API v63.0+) for object-level “View All Fields”.
181184
- [PermissionSet](https://developer.salesforce.com/docs/atlas.en-us.object_reference.meta/object_reference/sforce_api_objects_permissionset.htm)

messages/migrate.permset.copy.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,18 @@ Provide one or more permission targets with `--perms`:
88

99
- **Object only** (e.g. `Account`) — syncs **ObjectPermissions** for that object (create/read/edit/delete, etc.). Does not read FieldPermissions for that token.
1010
- **Field** (`Object.Field` or `Object:Field`, e.g. `Account.Name`) — syncs **FieldPermissions** for that field. Object-level permissions for that object are also synced for permission sets that have source field rows (narrow scope).
11+
- **All fields on an object** (`Object.*` or `Object:*`) — expands to every field on that object that is **FLS-enabled** in metadata (Tooling **FieldDefinition** / `IsFlsEnabled` on the **source** org), then syncs FieldPermissions like individual field tokens. Wildcard expansion uses the Tooling API so the list does not depend on the user’s field visibility.
1112

12-
You may mix tokens in one command (e.g. `Account,Contact.Email`).
13+
You may mix tokens in one command (e.g. `Account,Contact.Email` or `Contact.*`).
1314

1415
Queries FieldPermissions and ObjectPermissions on the source and target orgs as needed, matches permission sets by API name (PermissionSet.Name) and NamespacePrefix, computes the diff (source is the source of truth), then applies inserts, updates, and deletes on the target.
1516

17+
Wildcard expansion and preflight validation use the **Tooling API** (`FieldDefinition`, `EntityDefinition`) so object and field names come from org metadata; the running user must have Tooling access (e.g. View Setup and Configuration), not only FLS on the migrated fields.
18+
1619
By default, only permission sets with IsOwnedByProfile = false are considered. Use --include-profile-owned to include profile-owned (shadow) permission sets.
1720

21+
By default, fields (and objects) that are not present on the **target** org are **skipped** with a warning so the rest of the migration still runs. Use **--strict** to fail the command instead when anything in `--perms` is missing on the target.
22+
1823
# examples
1924

2025
- Copy FLS for two fields between aliases `org1` and `org2`:
@@ -29,6 +34,10 @@ By default, only permission sets with IsOwnedByProfile = false are considered. U
2934

3035
<%= config.bin %> <%= command.id %> -s org1 -t org2 --perms "Object**c:Field**c" --include-profile-owned
3136

37+
- Sync all permissionable Contact fields (wildcard):
38+
39+
<%= config.bin %> <%= command.id %> -s org1 -t org2 --perms "Contact.\*"
40+
3241
# flags.source-org.summary
3342

3443
Username or alias of the org to read permissions from.
@@ -39,12 +48,16 @@ Username or alias of the org to update.
3948

4049
# flags.perms.summary
4150

42-
Comma-separated list: object API name for object-level permissions only, or Object.Field / Object:Field for field-level (FLS) permissions only.
51+
Comma-separated list: object API name for object-level permissions only; Object.Field or Object:Field for one field; Object._ or Object:_ for all FLS-enabled fields on that object (expanded using Tooling FieldDefinition on the source org).
4352

4453
# flags.include-profile-owned.summary
4554

4655
Include permission sets owned by profiles (IsOwnedByProfile = true).
4756

57+
# flags.strict.summary
58+
59+
Fail if any object or field from --perms is missing on the target org. When omitted, missing target fields/objects are skipped with warnings.
60+
4861
# errors.SourceOrgRequired
4962

5063
You must provide a source org username or alias with --source-org.
@@ -61,6 +74,10 @@ Source and target org must be different usernames.
6174

6275
Provide at least one object or field in --perms.
6376

77+
# errors.NoSpecsRemain
78+
79+
Nothing remains to sync after matching the target org (all targets were missing on the target). Broaden --perms or use a source org that aligns with the target, or use --strict to see the first validation error instead.
80+
6481
# info.start
6582

6683
Syncing permission sets for %s permission target(s)...

src/commands/migrate/permset/copy.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
import { Messages, Org, SfError } from '@salesforce/core';
66
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
7+
import { expandPermWildcards } from '../../../shared/expandPermWildcards.js';
78
import { parsePerms } from '../../../shared/fields.js';
89
import { validatePermMetadata } from '../../../shared/metadata.js';
10+
import type { FieldNameCache } from '../../../shared/toolingSchema.js';
911
import { syncPermissionSets, type SyncResult } from '../../../shared/syncPermissions.js';
1012

1113
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
@@ -36,13 +38,16 @@ export default class MigratePermsetCopy extends SfCommand<SyncResult> {
3638
'include-profile-owned': Flags.boolean({
3739
summary: messages.getMessage('flags.include-profile-owned.summary'),
3840
}),
41+
strict: Flags.boolean({
42+
summary: messages.getMessage('flags.strict.summary'),
43+
}),
3944
'api-version': Flags.orgApiVersion(),
4045
};
4146

4247
public async run(): Promise<SyncResult> {
4348
const { flags } = await this.parse(MigratePermsetCopy);
4449

45-
// 1) Parse --perms; 2) resolve orgs; 3) describe metadata; 4) sync on target.
50+
// 1) Parse --perms; 2) resolve orgs; 3) Tooling schema validation; 4) sync on target.
4651
let specs;
4752
try {
4853
specs = parsePerms(flags.perms);
@@ -66,12 +71,35 @@ export default class MigratePermsetCopy extends SfCommand<SyncResult> {
6671
const source = sourceOrg.getConnection(flags['api-version']);
6772
const target = targetOrg.getConnection(flags['api-version']);
6873

69-
const { warnings: metaWarnings } = await validatePermMetadata(source, target, specs);
74+
const sourceFieldCache: FieldNameCache = new Map();
75+
const targetFieldCache: FieldNameCache = new Map();
76+
77+
let expanded;
78+
try {
79+
expanded = await expandPermWildcards(source, specs, sourceFieldCache);
80+
} catch (e) {
81+
const err = e instanceof Error ? e : new Error(String(e));
82+
throw new SfError(err.message, 'WildcardExpandError');
83+
}
84+
85+
if (expanded.length === 0) {
86+
throw messages.createError('errors.EmptyPerms');
87+
}
88+
89+
const { warnings: metaWarnings, specs: validatedSpecs } = await validatePermMetadata(source, target, expanded, {
90+
sourceFieldCache,
91+
targetFieldCache,
92+
strict: Boolean(flags.strict),
93+
});
94+
95+
if (validatedSpecs.length === 0) {
96+
throw messages.createError('errors.NoSpecsRemain');
97+
}
7098

71-
this.log(messages.getMessage('info.start', [String(specs.length)]));
99+
this.log(messages.getMessage('info.start', [String(validatedSpecs.length)]));
72100

73101
const profileOwned = Boolean(flags['include-profile-owned']);
74-
const result = await syncPermissionSets(source, target, specs, profileOwned, metaWarnings);
102+
const result = await syncPermissionSets(source, target, validatedSpecs, profileOwned, metaWarnings);
75103

76104
if (result.skippedPermissionSets.length > 0) {
77105
this.warn(messages.getMessage('info.skippedPs', [String(result.skippedPermissionSets.length)]));

src/shared/expandPermWildcards.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { Connection } from '@salesforce/core';
2+
import type { FieldPermSpec, PermSpec } from './fields.js';
3+
import type { FieldNameCache } from './toolingSchema.js';
4+
import { loadPermissionableFieldNamesForEntity } from './toolingSchema.js';
5+
6+
/** Dedupe object and field entries after wildcard expansion. */
7+
function dedupePermSpecs(specs: PermSpec[]): PermSpec[] {
8+
const m = new Map<string, PermSpec>();
9+
for (const s of specs) {
10+
if (s.kind === 'object') {
11+
m.set(s.canonical, s);
12+
} else if (s.kind === 'field') {
13+
m.set(s.canonicalField, s);
14+
}
15+
}
16+
return [...m.values()];
17+
}
18+
19+
/**
20+
* Replace each `Object.*` token with concrete `FieldPermSpec` rows using Tooling `FieldDefinition` on `conn`
21+
* (typically the source org). Listing is metadata-based and does not depend on FLS visibility for the user.
22+
*/
23+
export async function expandPermWildcards(
24+
conn: Connection,
25+
specs: PermSpec[],
26+
fieldNameCache?: FieldNameCache
27+
): Promise<PermSpec[]> {
28+
const chunks = await Promise.all(
29+
specs.map(async (s) => {
30+
if (s.kind !== 'fieldWildcard') {
31+
return [s];
32+
}
33+
const fieldNames = await loadPermissionableFieldNamesForEntity(conn, s.objectApiName, fieldNameCache);
34+
const fields: FieldPermSpec[] = [];
35+
for (const fieldApiName of fieldNames) {
36+
fields.push({
37+
kind: 'field',
38+
objectApiName: s.objectApiName,
39+
fieldApiName,
40+
canonicalField: `${s.objectApiName}.${fieldApiName}`,
41+
});
42+
}
43+
return fields;
44+
})
45+
);
46+
return dedupePermSpecs(chunks.flat());
47+
}

src/shared/fields.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,18 @@ export type FieldPermSpec = {
1919
canonicalField: string;
2020
};
2121

22-
export type PermSpec = ObjectPermSpec | FieldPermSpec;
22+
/**
23+
* Expands to one FieldPermSpec per permissionable field on the object (Tooling FieldDefinition on the source org).
24+
* Parse `Object.*` or `Object:*`.
25+
*/
26+
export type FieldWildcardSpec = {
27+
kind: 'fieldWildcard';
28+
objectApiName: string;
29+
/** e.g. `Contact.*` */
30+
canonical: string;
31+
};
32+
33+
export type PermSpec = ObjectPermSpec | FieldPermSpec | FieldWildcardSpec;
2334

2435
/** Split one token on the first `.` or `:` (whichever appears first if both exist). Returns null if neither. */
2536
function firstObjectFieldSeparator(token: string): number {
@@ -32,8 +43,8 @@ function firstObjectFieldSeparator(token: string): number {
3243
}
3344

3445
/**
35-
* Comma-separated tokens: `Account` (object perms only), or `Object.Field` / `Object:Field` (field perms only).
36-
* Deduped by canonical string per token kind.
46+
* Comma-separated tokens: `Account` (object perms only), `Object.*` (all permissionable fields), or
47+
* `Object.Field` / `Object:Field` (single field). Deduped by canonical string per token kind.
3748
*/
3849
export function parsePerms(raw: string): PermSpec[] {
3950
const parts = raw
@@ -59,6 +70,13 @@ export function parsePerms(raw: string): PermSpec[] {
5970
if (!objectApiName || !fieldApiName) {
6071
throw new Error(`Invalid permission token "${p}": expected Object.Field or Object:Field`);
6172
}
73+
if (fieldApiName === '*') {
74+
const canonical = `${objectApiName}.*`;
75+
if (!byCanon.has(canonical)) {
76+
byCanon.set(canonical, { kind: 'fieldWildcard', objectApiName, canonical });
77+
}
78+
continue;
79+
}
6280
const canonicalField = `${objectApiName}.${fieldApiName}`;
6381
if (!byCanon.has(canonicalField)) {
6482
byCanon.set(canonicalField, { kind: 'field', objectApiName, fieldApiName, canonicalField });
@@ -67,7 +85,7 @@ export function parsePerms(raw: string): PermSpec[] {
6785
return [...byCanon.values()];
6886
}
6987

70-
/** Distinct object API names from all permission specs (field + object-only). */
88+
/** Distinct object API names from all permission specs (field, wildcard, and object-only). */
7189
export function uniqueObjectNamesFromPerms(specs: PermSpec[]): string[] {
7290
return [...new Set(specs.map((s) => s.objectApiName))];
7391
}

0 commit comments

Comments
 (0)