cumulusci-plus 5.0.24__py3-none-any.whl → 5.0.25__py3-none-any.whl

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.

Potentially problematic release.


This version of cumulusci-plus might be problematic. Click here for more details.

@@ -0,0 +1,441 @@
1
+ import os
2
+ from typing import Any, Dict, List, Optional
3
+
4
+ from pydantic import root_validator
5
+
6
+ from cumulusci.core.exceptions import SalesforceDXException
7
+ from cumulusci.salesforce_api.utils import get_simple_salesforce_connection
8
+ from cumulusci.tasks.salesforce import BaseSalesforceApiTask
9
+ from cumulusci.utils.options import CCIOptions, Field
10
+
11
+
12
+ class NamedCredentialCalloutOptions(CCIOptions):
13
+ # Callout options
14
+ allow_merge_fields_in_body: bool = Field(
15
+ None,
16
+ description="Allow merge fields in body. [default to None]",
17
+ )
18
+ allow_merge_fields_in_header: bool = Field(
19
+ None,
20
+ description="Allow merge fields in header. [default to None]",
21
+ )
22
+ generate_authorization_header: bool = Field(
23
+ None,
24
+ description="Generate authorization header. [default to None]",
25
+ )
26
+
27
+
28
+ class NamedCredentialHttpHeader(CCIOptions):
29
+ name: str = Field(
30
+ None,
31
+ description="Name. [default to None]",
32
+ )
33
+ value: str = Field(
34
+ None,
35
+ description="Value. [default to None]",
36
+ )
37
+ sequence_number: int = Field(
38
+ None,
39
+ description="Sequence number. [default to None]",
40
+ )
41
+ secret: bool = Field(
42
+ False,
43
+ description="Is the value a secret. [default to False]",
44
+ )
45
+
46
+
47
+ class NamedCredentialParameter(CCIOptions):
48
+ allowed_managed_package_namespaces: str = Field(
49
+ None,
50
+ description="Allowed managed package namespaces. [default to None]",
51
+ )
52
+ url: str = Field(
53
+ None,
54
+ description="Url. [default to None]",
55
+ )
56
+ authentication: str = Field(
57
+ None,
58
+ description="Authentication. [default to None]",
59
+ )
60
+ certificate: str = Field(
61
+ None,
62
+ description="Certificate. [default to None]",
63
+ )
64
+ http_header: List["NamedCredentialHttpHeader"] = Field(
65
+ [],
66
+ description="Http header. [default to empty list]",
67
+ )
68
+
69
+ @root_validator
70
+ def check_parameters(cls, values):
71
+ """Check if only one of the parameters is provided"""
72
+ if (
73
+ len(
74
+ ([values.get("url")] if values.get("url") else [])
75
+ + (
76
+ [values.get("allowed_managed_package_namespaces")]
77
+ if values.get("allowed_managed_package_namespaces")
78
+ else []
79
+ )
80
+ + (
81
+ [values.get("authentication")]
82
+ if values.get("authentication")
83
+ else []
84
+ )
85
+ + ([values.get("certificate")] if values.get("certificate") else [])
86
+ + (
87
+ [len(values.get("http_header"))]
88
+ if values.get("http_header")
89
+ else []
90
+ )
91
+ )
92
+ == 1
93
+ ):
94
+ return values
95
+ raise ValueError("Only one of the parameters is required.")
96
+
97
+ def param_type(self):
98
+ """Get the parameter type"""
99
+ if self.url:
100
+ return "Url"
101
+ if self.authentication:
102
+ return "Authentication"
103
+ if self.certificate:
104
+ return "ClientCertificate"
105
+ if self.allowed_managed_package_namespaces:
106
+ return "AllowedManagedPackageNamespaces"
107
+ if self.http_header:
108
+ return "HttpHeader"
109
+
110
+ return None
111
+
112
+ def param_value(self, http_header=None):
113
+ """Get the parameter value"""
114
+ if self.url:
115
+ return self.url
116
+ if self.authentication:
117
+ return self.authentication
118
+ if self.certificate:
119
+ return self.certificate
120
+ if self.allowed_managed_package_namespaces:
121
+ return self.allowed_managed_package_namespaces
122
+ if http_header:
123
+ http_header_item = next(
124
+ (item for item in self.http_header if item.name == http_header), None
125
+ )
126
+ if http_header_item:
127
+ return http_header_item.value
128
+
129
+ def get_parameter_to_update(self):
130
+ """Get the parameter to update"""
131
+ ret = []
132
+
133
+ if len(self.http_header) > 0:
134
+ for http_header_item in self.http_header:
135
+ param_to_update = {}
136
+ param_to_update["parameterName"] = http_header_item.name
137
+ param_to_update["parameterType"] = "HttpHeader"
138
+ param_to_update["parameterValue"] = self.param_value(
139
+ http_header=http_header_item.name
140
+ )
141
+ param_to_update["secret"] = http_header_item.secret
142
+
143
+ if http_header_item.sequence_number is not None:
144
+ param_to_update["sequenceNumber"] = http_header_item.sequence_number
145
+
146
+ ret.append(param_to_update.copy())
147
+ else:
148
+ param_to_update = {}
149
+ param_to_update["parameterType"] = self.param_type()
150
+ param_to_update["parameterValue"] = self.param_value()
151
+
152
+ if param_to_update["parameterType"] == "ClientCertificate":
153
+ param_to_update["parameterName"] = "ClientCertificate"
154
+ param_to_update["certificate"] = param_to_update["parameterValue"]
155
+
156
+ if param_to_update["parameterType"] == "Authentication":
157
+ param_to_update["parameterName"] = "ExternalCredential"
158
+ param_to_update["externalCredential"] = param_to_update[
159
+ "parameterValue"
160
+ ]
161
+
162
+ ret.append(param_to_update.copy())
163
+
164
+ return ret
165
+
166
+
167
+ class TransformNamedCredentialParameter(NamedCredentialParameter):
168
+ def param_value(self, http_header=None):
169
+ value = super().param_value(http_header)
170
+ if value:
171
+ return os.getenv(value)
172
+ return None
173
+
174
+
175
+ NamedCredentialParameter.update_forward_refs()
176
+ TransformNamedCredentialParameter.update_forward_refs()
177
+
178
+
179
+ class UpdateNamedCredential(BaseSalesforceApiTask):
180
+ """Custom task to update named credential parameters.
181
+ This task is based on the managability rules of named credentials.
182
+ https://developer.salesforce.com/docs/atlas.en-us.pkg2_dev.meta/pkg2_dev/packaging_packageable_components.htm#mdc_named_credential
183
+ """
184
+
185
+ class Options(CCIOptions):
186
+ name: str = Field(..., description="Name of the named credential to update.")
187
+ namespace: str = Field(
188
+ "",
189
+ description="Namespace of the named credential to update. [default to empty string]",
190
+ )
191
+ # Callout options
192
+ callout_options: NamedCredentialCalloutOptions = Field(
193
+ None,
194
+ description="Callout options. [default to None]",
195
+ )
196
+ # Named credential parameters
197
+ parameters: List[NamedCredentialParameter] = Field(
198
+ [],
199
+ description="Parameters to update. [default to empty list]",
200
+ )
201
+ # Named credential parameters
202
+ transform_parameters: List[TransformNamedCredentialParameter] = Field(
203
+ [],
204
+ description="Parameters to transform. [default to empty list]",
205
+ )
206
+
207
+ parsed_options: Options
208
+
209
+ def _init_task(self):
210
+ self.tooling = get_simple_salesforce_connection(
211
+ self.project_config,
212
+ self.org_config,
213
+ api_version=self.project_config.project__package__api_version,
214
+ base_url="tooling",
215
+ )
216
+
217
+ def _run_task(self):
218
+ # Step 1: Get the named credential id from the named credential name
219
+ named_credential_id = self._get_named_credential_id()
220
+
221
+ if not named_credential_id:
222
+ msg = f"Named credential '{self.parsed_options.name}' not found"
223
+ raise SalesforceDXException(msg)
224
+
225
+ # Step 2: Get the named credential object
226
+ named_credential = self._get_named_credential_object(named_credential_id)
227
+
228
+ if not named_credential:
229
+ msg = f"Failed to retrieve named credential object for '{self.parsed_options.name}'"
230
+ raise SalesforceDXException(msg)
231
+
232
+ if named_credential.get("namedCredentialType") != "SecuredEndpoint":
233
+ msg = f"Named credential '{self.parsed_options.name}' is not a secured endpoint, Aborting update. Only SecuredEndpoint is supported."
234
+ raise SalesforceDXException(msg)
235
+
236
+ # Step 3: Update the named credential parameters
237
+ self._update_named_credential_parameters(named_credential)
238
+
239
+ updated_named_credential = self._update_named_credential_object(
240
+ named_credential_id, named_credential
241
+ )
242
+
243
+ if not updated_named_credential:
244
+ msg = f"Failed to update named credential object for '{self.parsed_options.name}'"
245
+ raise SalesforceDXException(msg)
246
+
247
+ self.logger.info(
248
+ f"Successfully updated named credential '{self.parsed_options.name}'"
249
+ )
250
+
251
+ def _get_named_credential_id(self) -> Optional[str]:
252
+ """Get the named credential ID from the named credential name"""
253
+ query = f"SELECT Id FROM NamedCredential WHERE DeveloperName='{self.parsed_options.name}'"
254
+
255
+ if self.parsed_options.namespace:
256
+ query += f" AND NamespacePrefix='{self.parsed_options.namespace}'"
257
+
258
+ query += " LIMIT 1"
259
+
260
+ try:
261
+ res = self.tooling.query(query)
262
+ if res["size"] == 0:
263
+ return None
264
+ return res["records"][0]["Id"]
265
+ except Exception as e:
266
+ self.logger.error(f"Error querying named credential: {str(e)}")
267
+ return None
268
+
269
+ def _get_named_credential_object(
270
+ self, named_credential_id: str
271
+ ) -> Optional[Dict[str, Any]]:
272
+ """Get the named credential object using Metadata API"""
273
+ try:
274
+ # Use Tooling API to get the named credential metadata
275
+ result = self.tooling._call_salesforce(
276
+ method="GET",
277
+ url=f"{self.tooling.base_url}sobjects/NamedCredential/{named_credential_id}",
278
+ )
279
+
280
+ if result.status_code != 200:
281
+ self.logger.error(
282
+ f"Error retrieving named credential object: {result.json()}"
283
+ )
284
+ return None
285
+
286
+ return result.json().get("Metadata", None)
287
+
288
+ except Exception as e:
289
+ self.logger.error(f"Error retrieving named credential object: {str(e)}")
290
+ return None
291
+
292
+ def _update_named_credential_object(
293
+ self, named_credential_id: str, named_credential: Dict[str, Any]
294
+ ) -> Optional[bool]:
295
+ """Update the named credential object"""
296
+ try:
297
+ resultUpdate = self.tooling._call_salesforce(
298
+ method="PATCH",
299
+ url=f"{self.tooling.base_url}sobjects/NamedCredential/{named_credential_id}",
300
+ json={"Metadata": named_credential},
301
+ )
302
+ if not resultUpdate.ok:
303
+ self.logger.error(
304
+ f"Error updating named credential object: {resultUpdate.json()}"
305
+ )
306
+ return None
307
+
308
+ return resultUpdate.ok
309
+ except Exception as e:
310
+ raise SalesforceDXException(
311
+ f"Failed to update named credential object: {str(e)}"
312
+ )
313
+
314
+ def _update_named_credential_parameters(self, named_credential: Dict[str, Any]):
315
+ """Update the named credential parameters"""
316
+ try:
317
+ template_param = self._get_named_credential_template_parameter(
318
+ named_credential
319
+ )
320
+
321
+ self._update_callout_options(named_credential)
322
+ self._update_parameters(
323
+ named_credential, self.parsed_options.parameters, template_param
324
+ )
325
+ self._update_parameters(
326
+ named_credential,
327
+ self.parsed_options.transform_parameters,
328
+ template_param,
329
+ )
330
+
331
+ except Exception as e:
332
+ raise SalesforceDXException(f"Failed to update parameters: {str(e)}")
333
+
334
+ def _update_callout_options(self, named_credential: Dict[str, Any]):
335
+ """Update the callout options"""
336
+ if not self.parsed_options.callout_options:
337
+ return
338
+
339
+ if self.parsed_options.callout_options.allow_merge_fields_in_body:
340
+ named_credential[
341
+ "allowMergeFieldsInBody"
342
+ ] = self.parsed_options.callout_options.allow_merge_fields_in_body
343
+ if self.parsed_options.callout_options.allow_merge_fields_in_header:
344
+ named_credential[
345
+ "allowMergeFieldsInHeader"
346
+ ] = self.parsed_options.callout_options.allow_merge_fields_in_header
347
+ if self.parsed_options.callout_options.generate_authorization_header:
348
+ named_credential[
349
+ "generateAuthorizationHeader"
350
+ ] = self.parsed_options.callout_options.generate_authorization_header
351
+
352
+ def _update_parameters(
353
+ self,
354
+ named_credential: Dict[str, Any],
355
+ named_credential_parameters: List[NamedCredentialParameter],
356
+ template_param: Dict[str, Any],
357
+ ):
358
+ """Update the parameters"""
359
+ for param_input in named_credential_parameters:
360
+ params_to_update = param_input.get_parameter_to_update()
361
+
362
+ for param_to_update in params_to_update:
363
+ secret = param_to_update.pop("secret", False)
364
+
365
+ param_to_update_copy = param_to_update.copy()
366
+ param_to_update_copy.pop("parameterValue", None)
367
+ param_to_update_copy.pop("certificate", None)
368
+ param_to_update_copy.pop("externalCredential", None)
369
+
370
+ cred_param = next(
371
+ (
372
+ param
373
+ for param in named_credential.get(
374
+ "namedCredentialParameters", []
375
+ )
376
+ if param_to_update_copy.items() <= param.items()
377
+ ),
378
+ None,
379
+ )
380
+
381
+ if cred_param:
382
+ cred_param.update(param_to_update)
383
+ self.logger.info(
384
+ f"Updated parameter {cred_param['parameterType']}-{cred_param['parameterName']} with new value {param_to_update['parameterValue'] if not secret else '********'}"
385
+ )
386
+ else:
387
+ copy_template_param = template_param.copy()
388
+ copy_template_param.update(param_to_update)
389
+ named_credential["namedCredentialParameters"].append(
390
+ copy_template_param
391
+ )
392
+ self.logger.info(
393
+ f"Added parameter {copy_template_param['parameterType']}-{copy_template_param['parameterName']} with new value {param_to_update['parameterValue'] if not secret else '********'}"
394
+ )
395
+
396
+ def _update_parameter_value(
397
+ self, named_credential_parameter: Dict[str, Any], new_value: str
398
+ ):
399
+ """Update an existing parameter's value"""
400
+ update_data = {"parameterValue": new_value}
401
+ named_credential_parameter.update(update_data)
402
+
403
+ def _get_named_credential_template_parameter(
404
+ self, named_credential: Dict[str, Any]
405
+ ) -> Dict[str, Any]:
406
+ """Get the named credential template parameter"""
407
+ template_param = [
408
+ param
409
+ for param in named_credential.get("namedCredentialParameters", [])
410
+ if param.get("parameterType") == "Url"
411
+ ]
412
+ if len(template_param) == 0:
413
+ self.logger.warning(
414
+ f"No template parameter found for named credential '{self.parsed_options.name}', using default template parameter."
415
+ )
416
+ return {
417
+ "certificate": None,
418
+ "description": None,
419
+ "externalCredential": None,
420
+ "globalNamedPrincipalCredential": None,
421
+ "managedFeatureEnabledCallout": None,
422
+ "outboundNetworkConnection": None,
423
+ "parameterName": None,
424
+ "parameterType": None,
425
+ "parameterValue": None,
426
+ "readOnlyNamedCredential": None,
427
+ "sequenceNumber": None,
428
+ "systemUserNamedCredential": None,
429
+ }
430
+
431
+ ret = template_param[0].copy()
432
+ ret.update(
433
+ {
434
+ "parameterName": None,
435
+ "parameterType": None,
436
+ "parameterValue": None,
437
+ "sequenceNumber": None,
438
+ }
439
+ )
440
+
441
+ return ret
@@ -2,18 +2,27 @@ import json
2
2
 
3
3
  from cumulusci.cli.ui import CliTable
4
4
  from cumulusci.core.exceptions import CumulusCIException
5
- from cumulusci.core.utils import process_list_arg
5
+ from cumulusci.core.utils import process_list_arg, process_bool_arg, determine_managed_mode
6
6
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
7
+ from cumulusci.utils import inject_namespace
7
8
 
8
9
 
9
10
  class AssignPermissionSets(BaseSalesforceApiTask):
10
11
  task_docs = """
11
12
  Assigns Permission Sets whose Names are in ``api_names`` to either the default org user or the user whose Alias is ``user_alias``. This task skips assigning Permission Sets that are already assigned.
13
+
14
+ Permission Set names can include namespace tokens that will be replaced based on the context:
15
+ - ``%%%NAMESPACE%%%`` is replaced with the package's namespace in managed contexts (e.g., when the package is installed)
16
+ - ``%%%NAMESPACED_ORG%%%`` is replaced with the package's namespace in namespaced orgs only (e.g., packaging orgs)
17
+ - ``%%%NAMESPACE_OR_C%%%`` is replaced with the namespace in managed contexts, or 'c' otherwise
18
+ - ``%%%NAMESPACED_ORG_OR_C%%%`` is replaced with the namespace in namespaced orgs, or 'c' otherwise
19
+
20
+ The managed mode and namespaced org detection is automatic based on the org context.
12
21
  """
13
22
 
14
23
  task_options = {
15
24
  "api_names": {
16
- "description": "API Names of desired Permission Sets, separated by commas.",
25
+ "description": "API Names of desired Permission Sets, separated by commas. Can include namespace tokens like %%%NAMESPACE%%%.",
17
26
  "required": True,
18
27
  },
19
28
  "user_alias": {
@@ -36,7 +45,51 @@ Assigns Permission Sets whose Names are in ``api_names`` to either the default o
36
45
  self.options.get("user_alias") or []
37
46
  )
38
47
 
48
+ def _init_namespace_injection(self):
49
+ """Initialize namespace injection options for processing permission set names.
50
+
51
+ This automatically determines managed mode and namespaced org context based on:
52
+ - Whether the package is installed in the org (managed mode)
53
+ - Whether we're in a packaging org (namespaced org)
54
+ """
55
+ namespace = self.project_config.project__package__namespace
56
+
57
+ # Automatically determine managed mode based on org context
58
+ managed = determine_managed_mode(
59
+ self.options, self.project_config, self.org_config
60
+ )
61
+
62
+ # Automatically determine if we're in a namespaced org (e.g., packaging org)
63
+ namespaced_org = (
64
+ bool(namespace) and namespace == getattr(self.org_config, "namespace", None)
65
+ )
66
+
67
+ # Store in options for use by inject_namespace
68
+ self.options["namespace_inject"] = namespace
69
+ self.options["managed"] = managed
70
+ self.options["namespaced_org"] = namespaced_org
71
+
72
+ def _inject_namespace(self, text):
73
+ """Inject the namespace into the given text if running in managed mode."""
74
+ if self.org_config is None:
75
+ return text
76
+ return inject_namespace(
77
+ "",
78
+ text,
79
+ namespace=self.options.get("namespace_inject"),
80
+ managed=self.options.get("managed") or False,
81
+ namespaced_org=self.options.get("namespaced_org"),
82
+ )[1]
83
+
39
84
  def _run_task(self):
85
+ # Initialize namespace injection only if tokens are present or options are set
86
+ if self.org_config and self._needs_namespace_injection():
87
+ self._init_namespace_injection()
88
+ # Process namespace tokens in api_names
89
+ self.options["api_names"] = [
90
+ self._inject_namespace(api_name) for api_name in self.options["api_names"]
91
+ ]
92
+
40
93
  users = self._query_existing_assignments()
41
94
  users_assigned_perms = {
42
95
  user["Id"]: self._get_assigned_perms(user) for user in users
@@ -51,6 +104,14 @@ Assigns Permission Sets whose Names are in ``api_names`` to either the default o
51
104
 
52
105
  self._insert_assignments(records_to_insert)
53
106
 
107
+ def _needs_namespace_injection(self):
108
+ """Check if namespace injection is needed based on presence of tokens in api_names."""
109
+ namespace_tokens = ["%%%NAMESPACE%%%", "%%%NAMESPACED_ORG%%%", "%%%NAMESPACE_OR_C%%%", "%%%NAMESPACED_ORG_OR_C%%%", "%%%NAMESPACE_DOT%%%"]
110
+ return any(
111
+ any(token in api_name for token in namespace_tokens)
112
+ for api_name in self.options["api_names"]
113
+ )
114
+
54
115
  def _query_existing_assignments(self):
55
116
  if not self.options["user_alias"]:
56
117
  query = (