auth0-deploy-cli 8.42.0 → 8.43.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/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [8.43.0] - 2026-08-12
11
+
12
+ ### Added
13
+
14
+ - Add org-to-app entitlement (EA) support for organizations. [#1454]
15
+ - Support full group metadata for Google Workspace directory sync (GA). [#1456]
16
+
17
+ ### Fixed
18
+
19
+ - Create phone templates on import instead of skipping when they have no ID. [#1457]
20
+ - Address path traversal vulnerability and false warnings in config file handlers. [#1449]
21
+ - Exclude org-scoped roles from tenant export by filtering `type='tenant'`. [#1453]
22
+ - Strip read-only field from roles to fix export/import round-trip. [#1445]
23
+
10
24
  ## [8.42.0] - 2026-07-30
11
25
 
12
26
  ### Added
@@ -1907,7 +1921,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1907
1921
  [#1436]: https://github.com/auth0/auth0-deploy-cli/issues/1436
1908
1922
  [#1437]: https://github.com/auth0/auth0-deploy-cli/issues/1437
1909
1923
  [#1438]: https://github.com/auth0/auth0-deploy-cli/issues/1438
1910
- [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v8.42.0...HEAD
1924
+ [#1445]: https://github.com/auth0/auth0-deploy-cli/issues/1445
1925
+ [#1449]: https://github.com/auth0/auth0-deploy-cli/issues/1449
1926
+ [#1453]: https://github.com/auth0/auth0-deploy-cli/issues/1453
1927
+ [#1454]: https://github.com/auth0/auth0-deploy-cli/issues/1454
1928
+ [#1456]: https://github.com/auth0/auth0-deploy-cli/issues/1456
1929
+ [#1457]: https://github.com/auth0/auth0-deploy-cli/issues/1457
1930
+ [Unreleased]: https://github.com/auth0/auth0-deploy-cli/compare/v8.43.0...HEAD
1931
+ [8.43.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.42.0...v8.43.0
1911
1932
  [8.42.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.41.0...v8.42.0
1912
1933
  [8.41.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.40.0...v8.41.0
1913
1934
  [8.40.0]: https://github.com/auth0/auth0-deploy-cli/compare/v8.39.0...v8.40.0
@@ -20,20 +20,19 @@ function parse(context) {
20
20
  disableKeywordReplacement: context.disableKeywordReplacement,
21
21
  }),
22
22
  };
23
- const moduleFolder = path_1.default.join(tools_1.constants.ACTION_MODULES_DIRECTORY, `${module.name}`);
24
23
  if (module.code) {
25
- // The `module.code` can be a file path. It needs to be loaded.
26
- // It can be a relative path, so we need to handle both cases.
27
- const unixPath = module.code.replace(/[\\/]+/g, '/').replace(/^([a-zA-Z]+:|\.\/)/, '');
28
- if (fs_extra_1.default.existsSync(unixPath)) {
29
- logger_1.default.warn(`Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` +
30
- `Please update your configuration to use paths relative to the config directory. ` +
31
- `Current absolute path used: ["${module.code}"]`);
32
- module.code = context.loadFile(unixPath, moduleFolder);
33
- }
34
- else {
35
- module.code = context.loadFile(path_1.default.join(context.filePath, module.code), moduleFolder);
24
+ const normalizedCode = module.code.replace(/\\/g, '/');
25
+ const configRoot = path_1.default.resolve(context.filePath);
26
+ const resolvedPath = path_1.default.resolve(context.filePath, normalizedCode);
27
+ if (!resolvedPath.startsWith(configRoot + path_1.default.sep)) {
28
+ logger_1.default.warn(`Path "${module.code}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` +
29
+ `This will be blocked as an error in the next major release. ` +
30
+ `Move the file inside your config directory.`);
36
31
  }
32
+ module.code = (0, tools_1.loadFileAndReplaceKeywords)(resolvedPath, {
33
+ mappings: context.mappings,
34
+ disableKeywordReplacement: context.disableKeywordReplacement,
35
+ });
37
36
  }
38
37
  return module;
39
38
  });
@@ -22,21 +22,19 @@ function parse(context) {
22
22
  disableKeywordReplacement: context.disableKeywordReplacement,
23
23
  }),
24
24
  };
25
- const actionFolder = path_1.default.join(tools_1.constants.ACTIONS_DIRECTORY, `${action.name}`);
26
25
  if (action.code) {
27
- // Convert `action.code` path to Unix-style path by replacing backslashes and multiple slashes with a single forward slash, and remove leading drive letters or './'.
28
- const unixPath = action.code.replace(/[\\/]+/g, '/').replace(/^([a-zA-Z]+:|\.\/)/, '');
29
- if (fs_extra_1.default.existsSync(unixPath)) {
30
- // If the Unix-style path exists, load the file from that path
31
- logger_1.default.warn(`Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` +
32
- `Please update your configuration to use paths relative to the config directory. ` +
33
- `Current absolute path used: ["${action.code}"]`);
34
- action.code = context.loadFile(unixPath, actionFolder);
35
- }
36
- else {
37
- // Otherwise, load the file from the context's file path
38
- action.code = context.loadFile(path_1.default.join(context.filePath, action.code), actionFolder);
26
+ const normalizedCode = action.code.replace(/\\/g, '/');
27
+ const configRoot = path_1.default.resolve(context.filePath);
28
+ const resolvedPath = path_1.default.resolve(context.filePath, normalizedCode);
29
+ if (!resolvedPath.startsWith(configRoot + path_1.default.sep)) {
30
+ logger_1.default.warn(`Path "${action.code}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` +
31
+ `This will be blocked as an error in the next major release. ` +
32
+ `Move the file inside your config directory.`);
39
33
  }
34
+ action.code = (0, tools_1.loadFileAndReplaceKeywords)(resolvedPath, {
35
+ mappings: context.mappings,
36
+ disableKeywordReplacement: context.disableKeywordReplacement,
37
+ });
40
38
  }
41
39
  return action;
42
40
  });
@@ -41,11 +41,11 @@ function getDatabase(folder, configRoot, mappingOpts) {
41
41
  }
42
42
  else {
43
43
  const resolvedBase = path_1.default.resolve(configRoot);
44
- const toLoad = path_1.default.resolve(folder, script);
44
+ const toLoad = path_1.default.resolve(folder, script.replace(/\\/g, '/'));
45
45
  if (!toLoad.startsWith(resolvedBase + path_1.default.sep)) {
46
- logger_1.default.warn(`Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` +
47
- `Please update your configuration to use paths relative to the config directory. ` +
48
- `Current absolute path used: ["${script}"]`);
46
+ logger_1.default.warn(`Path "${script}" resolves to "${toLoad}" which is outside the config directory "${resolvedBase}". ` +
47
+ `This will be blocked as an error in the next major release. ` +
48
+ `Move the file inside your config directory.`);
49
49
  }
50
50
  database.options.customScripts[name] = (0, tools_1.loadFileAndReplaceKeywords)(toLoad, mappingOpts);
51
51
  }
@@ -21,7 +21,18 @@ function parse(context) {
21
21
  }),
22
22
  };
23
23
  if (hook.script) {
24
- hook.script = context.loadFile(hook.script, tools_1.constants.HOOKS_DIRECTORY);
24
+ const normalizedScript = hook.script.replace(/\\/g, '/');
25
+ const configRoot = path_1.default.resolve(context.filePath);
26
+ const resolvedPath = path_1.default.resolve(context.filePath, tools_1.constants.HOOKS_DIRECTORY, normalizedScript);
27
+ if (!resolvedPath.startsWith(configRoot + path_1.default.sep)) {
28
+ logger_1.default.warn(`Path "${hook.script}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` +
29
+ `This will be blocked as an error in the next major release. ` +
30
+ `Move the file inside your config directory.`);
31
+ }
32
+ hook.script = (0, tools_1.loadFileAndReplaceKeywords)(resolvedPath, {
33
+ mappings: context.mappings,
34
+ disableKeywordReplacement: context.disableKeywordReplacement,
35
+ });
25
36
  }
26
37
  hook.name = hook.name.toLowerCase().replace(/\s/g, '-');
27
38
  return hook;
@@ -21,7 +21,18 @@ function parse(context) {
21
21
  }),
22
22
  };
23
23
  if (rule.script) {
24
- rule.script = context.loadFile(rule.script, tools_1.constants.RULES_DIRECTORY);
24
+ const normalizedScript = rule.script.replace(/\\/g, '/');
25
+ const configRoot = path_1.default.resolve(context.filePath);
26
+ const resolvedPath = path_1.default.resolve(context.filePath, tools_1.constants.RULES_DIRECTORY, normalizedScript);
27
+ if (!resolvedPath.startsWith(configRoot + path_1.default.sep)) {
28
+ logger_1.default.warn(`Path "${rule.script}" resolves to "${resolvedPath}" which is outside the config directory "${configRoot}". ` +
29
+ `This will be blocked as an error in the next major release. ` +
30
+ `Move the file inside your config directory.`);
31
+ }
32
+ rule.script = (0, tools_1.loadFileAndReplaceKeywords)(resolvedPath, {
33
+ mappings: context.mappings,
34
+ disableKeywordReplacement: context.disableKeywordReplacement,
35
+ });
25
36
  }
26
37
  return rule;
27
38
  });
@@ -68,14 +68,13 @@ class DirectoryContext {
68
68
  };
69
69
  }
70
70
  loadFile(f, folder) {
71
- const basePath = path.join(this.filePath, folder);
72
- let toLoad = path.join(basePath, f);
73
- if (!(0, utils_1.isFile)(toLoad)) {
74
- // try load not relative to yaml file
75
- toLoad = f;
76
- logger_1.default.warn(`Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` +
77
- `Please update your configuration to use paths relative to the config directory. ` +
78
- `Current absolute path used: ["${f}"]`);
71
+ const configRoot = path.resolve(this.filePath);
72
+ const basePath = path.resolve(this.filePath, folder);
73
+ const toLoad = path.resolve(basePath, f.replace(/\\/g, '/'));
74
+ if (!toLoad.startsWith(configRoot + path.sep)) {
75
+ logger_1.default.warn(`Path "${f}" resolves to "${toLoad}" which is outside the config directory "${configRoot}". ` +
76
+ `This will be blocked as an error in the next major release. ` +
77
+ `Move the file inside your config directory.`);
79
78
  }
80
79
  return (0, tools_1.loadFileAndReplaceKeywords)(toLoad, {
81
80
  mappings: this.mappings,
@@ -43,15 +43,14 @@ class YAMLContext {
43
43
  })();
44
44
  }
45
45
  loadFile(f) {
46
- let toLoad = path_1.default.join(this.basePath, f);
47
- if (!(0, utils_1.isFile)(toLoad)) {
48
- // try load not relative to yaml file
49
- toLoad = f;
50
- logger_1.default.warn(`Support for absolute paths and paths outside the config root will be deprecated in a future version to improve the security of the tool. ` +
51
- `Please update your configuration to use paths relative to the config directory. ` +
52
- `Current absolute path used: ["${f}"]`);
46
+ const configRoot = path_1.default.resolve(this.basePath);
47
+ const toLoad = path_1.default.resolve(this.basePath, f.replace(/\\/g, '/'));
48
+ if (!toLoad.startsWith(configRoot + path_1.default.sep)) {
49
+ logger_1.default.warn(`Path "${f}" resolves to "${toLoad}" which is outside the config directory "${configRoot}". ` +
50
+ `This will be blocked as an error in the next major release. ` +
51
+ `Move the file inside your config directory.`);
53
52
  }
54
- return (0, tools_1.loadFileAndReplaceKeywords)(path_1.default.resolve(toLoad), {
53
+ return (0, tools_1.loadFileAndReplaceKeywords)(toLoad, {
55
54
  mappings: this.mappings,
56
55
  disableKeywordReplacement: this.disableKeywordReplacement,
57
56
  });
@@ -159,6 +159,15 @@ export declare const schema: {
159
159
  id: {
160
160
  type: string;
161
161
  };
162
+ name: {
163
+ type: string;
164
+ };
165
+ email: {
166
+ type: string;
167
+ };
168
+ direct_members_count: {
169
+ type: string;
170
+ };
162
171
  };
163
172
  required: string[];
164
173
  };
@@ -173,9 +182,7 @@ type DirectoryProvisioningConfig = Management.DirectoryProvisioning;
173
182
  export type Connection = Management.ConnectionForList & {
174
183
  enabled_clients?: string[];
175
184
  directory_provisioning_configuration?: Pick<DirectoryProvisioningConfig, 'mapping' | 'synchronize_automatically' | 'synchronize_groups'> & {
176
- synchronized_groups?: Array<{
177
- id: string;
178
- }>;
185
+ synchronized_groups?: Management.SynchronizedGroupPayload[];
179
186
  };
180
187
  };
181
188
  export declare const addExcludedConnectionPropertiesToChanges: ({ proposedChanges, existingConnections, config, }: {
@@ -251,9 +258,7 @@ export default class ConnectionsHandler extends DefaultAPIHandler {
251
258
  /**
252
259
  * Retrieves all synchronized groups for a connection using checkpoint pagination.
253
260
  */
254
- getConnectionSynchronizedGroups(connectionId: string): Promise<Array<{
255
- id: string;
256
- }> | null>;
261
+ getConnectionSynchronizedGroups(connectionId: string): Promise<Management.SynchronizedGroupPayload[] | null>;
257
262
  /**
258
263
  * Replaces the synchronized groups for a connection (PUT replace-all semantics).
259
264
  */
@@ -172,6 +172,9 @@ exports.schema = {
172
172
  type: 'object',
173
173
  properties: {
174
174
  id: { type: 'string' },
175
+ name: { type: 'string' },
176
+ email: { type: 'string' },
177
+ direct_members_count: { type: 'number' },
175
178
  },
176
179
  required: ['id'],
177
180
  },
@@ -483,12 +486,28 @@ class ConnectionsHandler extends default_1.default {
483
486
  try {
484
487
  const groups = [];
485
488
  let page = await this.client.connections.directoryProvisioning.listSynchronizedGroups(connectionId, {});
486
- for (const g of page.data ?? [])
487
- groups.push({ id: g.id });
489
+ for (const g of page.data ?? []) {
490
+ groups.push({
491
+ id: g.id,
492
+ ...(g.name !== undefined && { name: g.name }),
493
+ ...(g.email !== undefined && { email: g.email }),
494
+ ...(g.direct_members_count !== undefined && {
495
+ direct_members_count: g.direct_members_count,
496
+ }),
497
+ });
498
+ }
488
499
  while (page.hasNextPage?.()) {
489
500
  page = await page.getNextPage();
490
- for (const g of page.data ?? [])
491
- groups.push({ id: g.id });
501
+ for (const g of page.data ?? []) {
502
+ groups.push({
503
+ id: g.id,
504
+ ...(g.name !== undefined && { name: g.name }),
505
+ ...(g.email !== undefined && { email: g.email }),
506
+ ...(g.direct_members_count !== undefined && {
507
+ direct_members_count: g.direct_members_count,
508
+ }),
509
+ });
510
+ }
492
511
  }
493
512
  return groups;
494
513
  }
@@ -462,12 +462,21 @@ class DatabaseHandler extends default_1.default {
462
462
  }
463
463
  return connection;
464
464
  });
465
+ // A connection can return both legacy password fields and `password_options` on read, but
466
+ // they're mutually exclusive on write. Strip the legacy fields to keep the export deployable.
467
+ const dbConnectionsNormalized = dbConnectionsWithActionNames.map((connection) => {
468
+ if (connection.options && 'password_options' in connection.options) {
469
+ const { passwordPolicy, password_complexity_options, password_history, password_no_personal_info, password_dictionary, ...options } = connection.options;
470
+ return { ...connection, options };
471
+ }
472
+ return connection;
473
+ });
465
474
  // If options option is empty for all connection, log the missing options scope.
466
- const isOptionExists = dbConnectionsWithActionNames.every((c) => c.options && Object.keys(c.options).length > 0);
475
+ const isOptionExists = dbConnectionsNormalized.every((c) => c.options && Object.keys(c.options).length > 0);
467
476
  if (!isOptionExists) {
468
477
  logger_1.default.warn(`Insufficient scope the read:connections_options scope is required to get ${this.type} options.`);
469
478
  }
470
- this.existing = dbConnectionsWithActionNames;
479
+ this.existing = dbConnectionsNormalized;
471
480
  return this.existing;
472
481
  }
473
482
  async calcChanges(assets) {
@@ -1,6 +1,7 @@
1
1
  import { Management } from 'auth0';
2
2
  import DefaultHandler from './default';
3
3
  import { Asset, Assets, CalculatedChanges } from '../../../types';
4
+ import { Client } from './clients';
4
5
  export declare const schema: {
5
6
  type: string;
6
7
  items: {
@@ -108,6 +109,25 @@ export declare const schema: {
108
109
  type: string;
109
110
  enum: ("allow" | "block")[];
110
111
  };
112
+ is_app_entitlement_active: {
113
+ type: string;
114
+ };
115
+ clients: {
116
+ type: string;
117
+ items: {
118
+ type: string;
119
+ properties: {
120
+ client_id: {
121
+ type: string;
122
+ };
123
+ use_for_member_access: {
124
+ type: string;
125
+ };
126
+ };
127
+ required: string[];
128
+ };
129
+ default: never[];
130
+ };
111
131
  };
112
132
  required: string[];
113
133
  };
@@ -119,6 +139,7 @@ type FormattedClientGrants = {
119
139
  export default class OrganizationsHandler extends DefaultHandler {
120
140
  existing: Asset[];
121
141
  formattedClientGrants: FormattedClientGrants[];
142
+ allClients: Client[];
122
143
  constructor(config: DefaultHandler);
123
144
  deleteOrganization(org: any): Promise<void>;
124
145
  deleteOrganizations(data: Asset[]): Promise<void>;
@@ -128,6 +149,7 @@ export default class OrganizationsHandler extends DefaultHandler {
128
149
  id: any;
129
150
  }>;
130
151
  getClientGrantIDByClientName(clientsName: string): string;
152
+ getClientIdByClientName(clientName: string): string;
131
153
  getFormattedClientGrants(): Promise<FormattedClientGrants[]>;
132
154
  updateOrganizations(updates: CalculatedChanges['update'], orgs: Asset[]): Promise<void>;
133
155
  getType(): Promise<Asset[]>;
@@ -141,5 +163,11 @@ export default class OrganizationsHandler extends DefaultHandler {
141
163
  createOrganizationDiscoveryDomain(organizationId: string, discoveryDomain: Management.CreateOrganizationDiscoveryDomainRequestContent): Promise<Management.CreateOrganizationDiscoveryDomainResponseContent>;
142
164
  updateOrganizationDiscoveryDomain(organizationId: string, discoveryDomainId: string, discoveryDomain: string, discoveryDomainUpdate: Management.UpdateOrganizationDiscoveryDomainRequestContent): Promise<Management.UpdateOrganizationDiscoveryDomainResponseContent>;
143
165
  deleteOrganizationDiscoveryDomain(organizationId: string, discoveryDomain: string, discoveryDomainId: string): Promise<void>;
166
+ getAllOrganizationClients(organizationId: string): Promise<Management.OrganizationClient[] | null>;
167
+ createOrganizationClients(organizationId: string, clients: Array<{
168
+ client_id: string;
169
+ use_for_member_access: boolean;
170
+ }>): Promise<void>;
171
+ deleteOrganizationClients(organizationId: string, clientIds: string[]): Promise<void>;
144
172
  }
145
173
  export {};
@@ -131,6 +131,19 @@ exports.schema = {
131
131
  type: 'string',
132
132
  enum: Object.values(auth0_1.Management.OrganizationThirdPartyClientAccessEnum),
133
133
  },
134
+ is_app_entitlement_active: { type: 'boolean' },
135
+ clients: {
136
+ type: 'array',
137
+ items: {
138
+ type: 'object',
139
+ properties: {
140
+ client_id: { type: 'string' },
141
+ use_for_member_access: { type: 'boolean' },
142
+ },
143
+ required: ['client_id', 'use_for_member_access'],
144
+ },
145
+ default: [],
146
+ },
134
147
  },
135
148
  required: ['name'],
136
149
  },
@@ -172,6 +185,7 @@ class OrganizationsHandler extends default_1.default {
172
185
  const organization = { ...org };
173
186
  delete organization.connections;
174
187
  delete organization.client_grants;
188
+ delete organization.clients;
175
189
  if ('discovery_domains' in organization) {
176
190
  delete organization.discovery_domains;
177
191
  }
@@ -195,6 +209,19 @@ class OrganizationsHandler extends default_1.default {
195
209
  throw new Error(`Problem creating discovery domain ${discoveryDomain?.domain} for organization ${createdId}\n${err}`);
196
210
  })));
197
211
  }
212
+ if (typeof org.clients !== 'undefined' && org.clients.length > 0) {
213
+ const clientsToCreate = org.clients
214
+ .map((oc) => ({
215
+ client_id: this.getClientIdByClientName(oc.client_id),
216
+ use_for_member_access: oc.use_for_member_access,
217
+ }))
218
+ .filter((oc) => !!oc.client_id);
219
+ if (clientsToCreate.length > 0) {
220
+ await this.createOrganizationClients(createdId, clientsToCreate).catch((err) => {
221
+ throw new Error(`Problem creating org clients for organization ${createdId}\n${err}`);
222
+ });
223
+ }
224
+ }
198
225
  return created;
199
226
  }
200
227
  async createOrganizations(creates) {
@@ -213,14 +240,15 @@ class OrganizationsHandler extends default_1.default {
213
240
  .promise();
214
241
  }
215
242
  async updateOrganization(org, organizations) {
216
- const { connections: existingConnections, client_grants: existingClientGrants, discovery_domains: existingDiscoveryDomains, } = await organizations.find((orgToUpdate) => orgToUpdate.name === org.name);
243
+ const { connections: existingConnections, client_grants: existingClientGrants, discovery_domains: existingDiscoveryDomains, clients: existingOrgClients = [], } = await organizations.find((orgToUpdate) => orgToUpdate.name === org.name);
217
244
  const params = { id: org.id };
218
- const { connections, client_grants: organizationClientGrants, discovery_domains: organizationDiscoveryDomains, } = org;
245
+ const { connections, client_grants: organizationClientGrants, discovery_domains: organizationDiscoveryDomains, clients: organizationClients, } = org;
219
246
  delete org.connections;
220
247
  delete org.name;
221
248
  delete org.id;
222
249
  delete org.client_grants;
223
250
  delete org.discovery_domains;
251
+ delete org.clients;
224
252
  await this.client.organizations.update(params.id, org);
225
253
  // organization connections
226
254
  const connectionsToRemove = existingConnections.filter((c) => !connections.find((x) => x.connection_id === c.connection_id));
@@ -324,12 +352,61 @@ class OrganizationsHandler extends default_1.default {
324
352
  \n${orgDiscoveryDomainsToRemove.map((i) => this.objString(i)).join('\n')}`);
325
353
  }
326
354
  }
355
+ // organization clients
356
+ const orgClientsToAdd = (organizationClients || [])
357
+ .filter((c) => !existingOrgClients.find((x) => x.client_id === c.client_id))
358
+ .map((oc) => ({
359
+ client_id: this.getClientIdByClientName(oc.client_id),
360
+ use_for_member_access: oc.use_for_member_access,
361
+ }))
362
+ .filter((oc) => !!oc.client_id);
363
+ const orgClientsToRemove = (existingOrgClients || [])
364
+ .filter((c) => !(organizationClients || []).find((x) => x.client_id === c.client_id))
365
+ .map((oc) => this.getClientIdByClientName(oc.client_id))
366
+ .filter(Boolean);
367
+ const orgClientsToUpdate = (organizationClients || [])
368
+ .filter((c) => {
369
+ const existing = existingOrgClients.find((x) => x.client_id === c.client_id);
370
+ return existing && existing.use_for_member_access !== c.use_for_member_access;
371
+ })
372
+ .map((oc) => ({
373
+ client_id: this.getClientIdByClientName(oc.client_id),
374
+ use_for_member_access: oc.use_for_member_access,
375
+ }))
376
+ .filter((oc) => !!oc.client_id);
377
+ if (orgClientsToAdd.length > 0) {
378
+ await this.createOrganizationClients(params.id, orgClientsToAdd).catch((err) => {
379
+ throw new Error(`Problem adding org clients for organization ${params.id}\n${err}`);
380
+ });
381
+ }
382
+ if (orgClientsToRemove.length > 0) {
383
+ if (this.config('AUTH0_ALLOW_DELETE') === 'true' ||
384
+ this.config('AUTH0_ALLOW_DELETE') === true) {
385
+ await this.deleteOrganizationClients(params.id, orgClientsToRemove).catch((err) => {
386
+ throw new Error(`Problem removing org clients for organization ${params.id}\n${err}`);
387
+ });
388
+ }
389
+ else {
390
+ logger_1.default.warn(`Detected the following organization client associations should be removed. Doing so may be destructive.\nYou can enable deletes by setting 'AUTH0_ALLOW_DELETE' to true in the config\n${orgClientsToRemove.join('\n')}`);
391
+ }
392
+ }
393
+ await Promise.all(orgClientsToUpdate.map((oc) => this.client.organizations.clients
394
+ .update(params.id, oc.client_id, {
395
+ use_for_member_access: oc.use_for_member_access,
396
+ })
397
+ .catch((err) => {
398
+ throw new Error(`Problem updating org client ${oc.client_id} for organization ${params.id}\n${err}`);
399
+ })));
327
400
  return params;
328
401
  }
329
402
  getClientGrantIDByClientName(clientsName) {
330
403
  const found = this.formattedClientGrants.find((c) => c.client_id === clientsName);
331
404
  return found?.grant_id || '';
332
405
  }
406
+ getClientIdByClientName(clientName) {
407
+ const found = this.allClients?.find((c) => c.name === clientName);
408
+ return found?.client_id || '';
409
+ }
333
410
  async getFormattedClientGrants() {
334
411
  const [clients, clientGrants] = await Promise.all([
335
412
  (0, client_1.paginate)(this.client.clients.list, {
@@ -339,6 +416,8 @@ class OrganizationsHandler extends default_1.default {
339
416
  paginate: true,
340
417
  }),
341
418
  ]);
419
+ // Store clients for org-client name→ID resolution
420
+ this.allClients = clients;
342
421
  // Convert clients by name to the id and store it in the formattedClientGrants
343
422
  const formattedClientGrantsMapping = clientGrants?.map((clientGrant) => {
344
423
  const { id, client_id: clientName } = clientGrant;
@@ -394,6 +473,14 @@ class OrganizationsHandler extends default_1.default {
394
473
  if (organizationDiscoveryDomains) {
395
474
  org.discovery_domains = organizationDiscoveryDomains;
396
475
  }
476
+ // Get org-client associations
477
+ const orgClients = await this.getAllOrganizationClients(org.id);
478
+ if (orgClients) {
479
+ org.clients = orgClients.map((oc) => ({
480
+ client_id: (0, utils_1.convertClientIdToName)(oc.client_id, clients),
481
+ use_for_member_access: oc.use_for_member_access,
482
+ }));
483
+ }
397
484
  }
398
485
  this.existing = organizations;
399
486
  return this.existing;
@@ -539,6 +626,44 @@ class OrganizationsHandler extends default_1.default {
539
626
  logger_1.default.debug(`Deleting discovery domain ${discoveryDomain} for organization ${organizationId}`);
540
627
  await this.client.organizations.discoveryDomains.delete(organizationId, discoveryDomainId);
541
628
  }
629
+ async getAllOrganizationClients(organizationId) {
630
+ const allOrgClients = [];
631
+ try {
632
+ let orgClients = await this.client.organizations.clients.list(organizationId);
633
+ allOrgClients.push(...orgClients.data);
634
+ while (orgClients.hasNextPage()) {
635
+ orgClients = await orgClients.getNextPage();
636
+ allOrgClients.push(...orgClients.data);
637
+ }
638
+ return allOrgClients;
639
+ }
640
+ catch (err) {
641
+ if (err.statusCode === 404 || err.statusCode === 501) {
642
+ return null;
643
+ }
644
+ if (err.statusCode === 403 || err.errorCode === 'feature_not_enabled') {
645
+ logger_1.default.debug('Org-to-app entitlement is not enabled for this tenant. Skipping org-client associations.');
646
+ return null;
647
+ }
648
+ throw err;
649
+ }
650
+ }
651
+ async createOrganizationClients(organizationId, clients) {
652
+ const BATCH_SIZE = 10;
653
+ for (let i = 0; i < clients.length; i += BATCH_SIZE) {
654
+ const batch = clients.slice(i, i + BATCH_SIZE);
655
+ logger_1.default.debug(`Creating ${batch.length} org client(s) for organization ${organizationId}`);
656
+ await this.client.organizations.clients.create(organizationId, { clients: batch });
657
+ }
658
+ }
659
+ async deleteOrganizationClients(organizationId, clientIds) {
660
+ const BATCH_SIZE = 10;
661
+ for (let i = 0; i < clientIds.length; i += BATCH_SIZE) {
662
+ const batch = clientIds.slice(i, i + BATCH_SIZE);
663
+ logger_1.default.debug(`Deleting ${batch.length} org client(s) for organization ${organizationId}`);
664
+ await this.client.organizations.clients.delete(organizationId, { clients: batch });
665
+ }
666
+ }
542
667
  }
543
668
  exports.default = OrganizationsHandler;
544
669
  __decorate([
@@ -54,7 +54,10 @@ export default class PhoneTemplatesHandler extends DefaultHandler {
54
54
  objString(template: any): string;
55
55
  getType(): Promise<PhoneTemplate[]>;
56
56
  processChanges(assets: Assets): Promise<void>;
57
- createPhoneTemplate(template: any): Promise<Asset>;
57
+ createPhoneTemplate(template: any): Promise<{
58
+ asset: Asset;
59
+ action: 'create' | 'update';
60
+ }>;
58
61
  createPhoneTemplates(creates: CalculatedChanges['create']): Promise<void>;
59
62
  updatePhoneTemplate(template: any): Promise<Asset>;
60
63
  updatePhoneTemplates(updates: CalculatedChanges['update']): Promise<void>;
@@ -44,6 +44,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.schema = void 0;
46
46
  const default_1 = __importStar(require("./default"));
47
+ const utils_1 = require("../../utils");
47
48
  const logger_1 = __importDefault(require("../../../logger"));
48
49
  exports.schema = {
49
50
  type: 'array',
@@ -121,25 +122,77 @@ class PhoneTemplatesHandler extends default_1.default {
121
122
  if (del.length > 0) {
122
123
  await this.deletePhoneTemplates(del);
123
124
  }
124
- if (create.length > 0) {
125
- await this.createPhoneTemplates(create);
125
+ // On newly created tenants the phone templates returned by the API have no
126
+ // ID until they are explicitly created. calcChanges matches assets by `type`
127
+ // and routes them all to `update`, but PATCH requires an ID. Split out the
128
+ // templates whose existing counterpart has no ID yet and create them instead.
129
+ const missingId = [];
130
+ const hasId = [];
131
+ update.forEach((template) => {
132
+ const existing = this.existing?.find((t) => t.type === template.type);
133
+ if (existing?.id) {
134
+ hasId.push(template);
135
+ }
136
+ else {
137
+ missingId.push(template);
138
+ }
139
+ });
140
+ const toCreate = [...create, ...missingId];
141
+ if (toCreate.length > 0) {
142
+ await this.createPhoneTemplates(toCreate);
126
143
  }
127
- if (update.length > 0) {
128
- await this.updatePhoneTemplates(update);
144
+ if (hasId.length > 0) {
145
+ await this.updatePhoneTemplates(hasId);
129
146
  }
130
147
  }
131
148
  async createPhoneTemplate(template) {
132
- const created = await this.client.branding.phone.templates.create(template);
133
- return created;
149
+ // The create endpoint only accepts type/disabled/content; strip read-only
150
+ // fields (channel, customizable, tenant) that the API would reject.
151
+ const createPayload = (0, utils_1.stripFields)(template, this.stripCreateFields);
152
+ // The API rejects an empty `content.from`, which is what fresh tenants
153
+ // export. Drop it when blank (cloning `content`, which stripFields only
154
+ // shallow-copied, so the caller's original asset isn't mutated).
155
+ if (createPayload.content && !createPayload.content.from) {
156
+ createPayload.content = { ...createPayload.content };
157
+ delete createPayload.content.from;
158
+ }
159
+ try {
160
+ const created = await this.client.branding.phone.templates.create(createPayload);
161
+ return { asset: created, action: 'create' };
162
+ }
163
+ catch (err) {
164
+ // A 409 means the template already exists on the tenant (it was created
165
+ // between our list call and this create, or the list endpoint didn't
166
+ // surface its ID). Re-fetch to pick up the ID and fall back to an update.
167
+ if (err.statusCode === 409) {
168
+ logger_1.default.debug(`Phone template type '${template.type}' already exists, falling back to update`);
169
+ // Reassigns the shared `this.existing`; safe under concurrency since
170
+ // every re-fetch returns the same consistent list. If it still lacks an
171
+ // ID, updatePhoneTemplate warns and skips.
172
+ const response = await this.client.branding.phone.templates.list();
173
+ this.existing = response.templates ?? [];
174
+ const updated = await this.updatePhoneTemplate(template);
175
+ return { asset: updated, action: 'update' };
176
+ }
177
+ throw err;
178
+ }
134
179
  }
135
180
  async createPhoneTemplates(creates) {
136
181
  await this.client.pool
137
182
  .addEachTask({
138
183
  data: creates || [],
139
184
  generator: (item) => this.createPhoneTemplate(item)
140
- .then((data) => {
141
- this.didCreate(data);
142
- this.created += 1;
185
+ .then(({ asset, action }) => {
186
+ // A 409 fallback actually performed an update; count it as such so
187
+ // the import summary doesn't report a phantom "create".
188
+ if (action === 'update') {
189
+ this.didUpdate(asset);
190
+ this.updated += 1;
191
+ }
192
+ else {
193
+ this.didCreate(asset);
194
+ this.created += 1;
195
+ }
143
196
  })
144
197
  .catch((err) => {
145
198
  throw new Error(`Problem creating ${this.type} ${this.objString(item)}\n${err}`);
@@ -160,7 +213,6 @@ class PhoneTemplatesHandler extends default_1.default {
160
213
  logger_1.default.debug(`Stripping ${this.type} create-only fields ${JSON.stringify(stripUpdateFields)}`);
161
214
  const updatePayload = {
162
215
  content: {
163
- from: template.content?.from,
164
216
  body: {
165
217
  text: template.content?.body?.text,
166
218
  voice: template.content?.body?.voice,
@@ -168,6 +220,11 @@ class PhoneTemplatesHandler extends default_1.default {
168
220
  },
169
221
  disabled: template.disabled,
170
222
  };
223
+ // `content.from` is optional but the API rejects an empty string, which is
224
+ // what fresh tenants export. Only include it when it has a value.
225
+ if (template.content?.from) {
226
+ updatePayload.content.from = template.content.from;
227
+ }
171
228
  const updated = await this.client.branding.phone.templates.update(existing.id, updatePayload);
172
229
  return updated;
173
230
  }
@@ -14,6 +14,10 @@ export declare const schema: {
14
14
  description: {
15
15
  type: string;
16
16
  };
17
+ type: {
18
+ type: string;
19
+ enum: string[];
20
+ };
17
21
  permissions: {
18
22
  type: string;
19
23
  items: {
@@ -56,6 +56,7 @@ exports.schema = {
56
56
  name: { type: 'string' },
57
57
  id: { type: 'string' },
58
58
  description: { type: 'string' },
59
+ type: { type: 'string', enum: ['tenant', 'organization'] },
59
60
  permissions: {
60
61
  type: 'array',
61
62
  items: {
@@ -81,6 +82,9 @@ class RolesHandler extends default_1.default {
81
82
  async createRole(data) {
82
83
  const role = { ...data };
83
84
  delete role.permissions;
85
+ // deploy-cli only manages tenant-level roles, so `type` is stripped on write; it
86
+ // defaults to `tenant` on create. This keeps exports (which carry `type`) round-trippable.
87
+ delete role.type;
84
88
  const created = await this.client.roles.create(role);
85
89
  if (created.id && typeof data.permissions !== 'undefined' && data.permissions.length > 0) {
86
90
  await this.client.roles.permissions.add(created.id, { permissions: data.permissions });
@@ -133,6 +137,9 @@ class RolesHandler extends default_1.default {
133
137
  const newPermissions = data.permissions;
134
138
  delete data.permissions;
135
139
  delete data.id;
140
+ // deploy-cli only manages tenant-level roles, so `type` is stripped on write; this
141
+ // lets files that already carry it (from a prior export) be imported cleanly.
142
+ delete data.type;
136
143
  await this.client.roles.update(params.id, data);
137
144
  if (typeof existingRole.permissions !== 'undefined' && existingRole.permissions.length > 0) {
138
145
  await this.client.roles.permissions.delete(params.id, {
@@ -164,9 +171,12 @@ class RolesHandler extends default_1.default {
164
171
  return this.existing;
165
172
  }
166
173
  try {
174
+ // Only manage tenant-level roles; exclude org-scoped roles surfaced when
175
+ // the api2_org_level_roles_ea flag is enabled.
167
176
  const roles = await (0, client_1.paginate)(this.client.roles.list, {
168
177
  paginate: true,
169
178
  include_totals: true,
179
+ type: 'tenant',
170
180
  });
171
181
  for (let index = 0; index < roles.length; index++) {
172
182
  const allPermission = [];
package/lib/types.d.ts CHANGED
@@ -31,6 +31,7 @@ type SharedPaginationParams = {
31
31
  include_totals?: boolean;
32
32
  id?: string;
33
33
  strategy?: Management.ConnectionStrategyEnum[];
34
+ type?: Management.RoleTypeEnum;
34
35
  };
35
36
  export type CheckpointPaginationParams = SharedPaginationParams & {
36
37
  from?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auth0-deploy-cli",
3
- "version": "8.42.0",
3
+ "version": "8.43.0",
4
4
  "description": "A command line tool for deploying updates to your Auth0 tenant",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
@@ -35,7 +35,7 @@
35
35
  "dependencies": {
36
36
  "@clack/prompts": "1.4.0",
37
37
  "ajv": "^6.12.6",
38
- "auth0": "^6.1.0",
38
+ "auth0": "^6.2.0",
39
39
  "chalk": "5.6.2",
40
40
  "dot-prop": "^5.3.0",
41
41
  "fs-extra": "^10.1.0",