1010# Builtins
1111import json
1212import logging
13+ import re
1314from typing import Optional , Tuple
1415
16+ from packaging .version import Version
17+
1518from conjur_api .errors .errors import ResourceNotFoundException , MissingRequiredParameterException , HttpStatusError
1619from conjur_api .http .api import Api
1720from conjur_api .interface .authentication_strategy_interface import AuthenticationStrategyInterface
2730
2831logger = logging .getLogger (__name__ )
2932
33+ # List of possible Secrets Manager SaaS URL suffixes
34+ _CONJUR_CLOUD_SUFFIXES = [
35+ ".cyberark.cloud" ,
36+ ".integration-cyberark.cloud" ,
37+ ".test-cyberark.cloud" ,
38+ ".dev-cyberark.cloud" ,
39+ ".cyberark-everest-integdev.cloud" ,
40+ ".cyberark-everest-pre-prod.cloud" ,
41+ ".sandbox-cyberark.cloud" ,
42+ ".pt-cyberark.cloud" ,
43+ ]
44+
45+ # Build regex pattern: (\\.secretsmgr|-secretsmanager) followed by one of the suffixes
46+ _SUFFIXES_PATTERN = "|" .join (re .escape (suffix ) for suffix in _CONJUR_CLOUD_SUFFIXES )
47+ _CONJUR_CLOUD_REGEXP = re .compile (
48+ rf"(\.secretsmgr|-secretsmanager)({ _SUFFIXES_PATTERN } )" ,
49+ re .IGNORECASE
50+ )
51+
3052@allow_sync_invocation ()
3153# pylint: disable=too-many-public-methods
3254class Client :
@@ -39,7 +61,8 @@ class Client:
3961 try :
4062 integration_version = version ("conjur_api" )
4163 except PackageNotFoundError :
42- integration_version = 'No Version Found'
64+ # setuptools defaults to "0.0.dev0" (PEP 440), so we use a default version that adheres to that for testing purposes
65+ integration_version = '0.0.dev0'
4366 integration_type = 'cybr-secretsmanager'
4467 vendor_name = 'CyberArk'
4568 vendor_version = None
@@ -95,6 +118,7 @@ def __init__(
95118 self .ssl_verification_mode = ssl_verification_mode
96119 self .connection_info = connection_info
97120 self .debug = debug
121+ self .conjur_version = None # Cache for server version
98122 self ._api = self ._create_api (http_debug , authn_strategy )
99123
100124 self .set_telemetry_header ()
@@ -293,23 +317,29 @@ async def set(self, variable_id: str, value: str) -> str:
293317 """
294318 await self ._api .set_variable (variable_id , value )
295319
296- async def load_policy_file (self , policy_name : str , policy_file : str ) -> dict :
320+ async def load_policy_file (self , policy_name : str , policy_file : str , dry_run : bool = False ) -> dict :
297321 """
298322 Applies a file-based policy to the Conjur instance
299323 """
300- return await self ._api .load_policy_file (policy_name , policy_file )
324+ if dry_run :
325+ await self ._validate_dry_run_support ()
326+ return await self ._api .load_policy_file (policy_name , policy_file , dry_run )
301327
302- async def replace_policy_file (self , policy_name : str , policy_file : str ) -> dict :
328+ async def replace_policy_file (self , policy_name : str , policy_file : str , dry_run : bool = False ) -> dict :
303329 """
304330 Replaces a file-based policy defined in the Conjur instance
305331 """
306- return await self ._api .replace_policy_file (policy_name , policy_file )
332+ if dry_run :
333+ await self ._validate_dry_run_support ()
334+ return await self ._api .replace_policy_file (policy_name , policy_file , dry_run )
307335
308- async def update_policy_file (self , policy_name : str , policy_file : str ) -> dict :
336+ async def update_policy_file (self , policy_name : str , policy_file : str , dry_run : bool = False ) -> dict :
309337 """
310338 Replaces a file-based policy defined in the Conjur instance
311339 """
312- return await self ._api .update_policy_file (policy_name , policy_file )
340+ if dry_run :
341+ await self ._validate_dry_run_support ()
342+ return await self ._api .update_policy_file (policy_name , policy_file , dry_run )
313343
314344 async def rotate_other_api_key (self , resource : Resource ) -> str :
315345 """
@@ -350,6 +380,108 @@ async def get_server_info(self):
350380 else :
351381 raise
352382
383+ def _is_version_less_than (self , version1 : str , version2 : str ) -> bool :
384+ """
385+ Checks if version1 is less than version2.
386+ @param version1: First version string (e.g., "1.21.1", "1.21.1-beta", "1.24.0-1049")
387+ @param version2: Second version string (e.g., "1.21.1")
388+ @return: True if version1 < version2, False otherwise
389+ """
390+ return Version (version1 ) < Version (version2 )
391+
392+ def _is_conjur_cloud_url (self , url : str ) -> bool :
393+ """
394+ Checks if the URL is a Conjur Cloud (Secrets Manager SaaS) URL.
395+ Matches the Go regex pattern: (\\ .secretsmgr|-secretsmanager) followed by one of the cloud suffixes.
396+ @param url: The Conjur URL to check
397+ @return: True if the URL is a Conjur Cloud URL, False otherwise
398+ """
399+ if not url :
400+ return False
401+
402+ return bool (_CONJUR_CLOUD_REGEXP .search (url ))
403+
404+ async def server_version (self ) -> str :
405+ """
406+ Retrieves the Conjur server version, either from the '/info' endpoint in Secrets Manager Self-Hosted,
407+ or from the root endpoint in Conjur OSS. The version returned corresponds to the Conjur OSS version,
408+ which in Conjur Enterprise is the version of the 'possum' service.
409+
410+ The version is cached after the first retrieval to avoid making multiple requests.
411+
412+ @return: Server version string
413+ @raises: Exception if unable to retrieve server version or if running against Conjur Cloud
414+ """
415+ # Return cached version if available
416+ if self .conjur_version is not None :
417+ return self .conjur_version
418+
419+ url = self .connection_info .conjur_url
420+
421+ if self ._is_conjur_cloud_url (url ):
422+ raise Exception ("Unable to retrieve server version: not supported in Secrets Manager SaaS" )
423+
424+ # Try to get enterprise server info first
425+ enterprise_error = None
426+ try :
427+ info = await self .get_server_info ()
428+ # Return the version of the 'possum' service, which corresponds to the Conjur OSS version
429+ if isinstance (info , dict ) and 'services' in info :
430+ services = info .get ('services' , {})
431+ if 'possum' in services :
432+ possum_service = services .get ('possum' , {})
433+ if isinstance (possum_service , dict ) and 'version' in possum_service :
434+ self .conjur_version = possum_service ['version' ]
435+ return self .conjur_version
436+ except Exception as err :
437+ # If enterprise info fails, try root endpoint
438+ enterprise_error = err
439+
440+ # Try to get version from root endpoint (Conjur OSS)
441+ try :
442+ version = await self ._api .get_server_version_from_root ()
443+ if version :
444+ self .conjur_version = version
445+ return self .conjur_version
446+ except Exception as root_err :
447+ # Both methods failed, raise an error with details
448+ error_msg = "failed to retrieve server version"
449+ if enterprise_error :
450+ error_msg += f": enterprise info error - { enterprise_error } "
451+ if root_err :
452+ error_msg += f", root endpoint error - { root_err } "
453+ raise Exception (error_msg ) from root_err
454+
455+ raise Exception ("failed to retrieve server version: both enterprise info and root endpoint failed" )
456+
457+ async def _validate_dry_run_support (self ):
458+ """
459+ Validates that dry_run is supported by checking:
460+ 1. The server is not Conjur Cloud (SaaS)
461+ 2. The server version is >= 1.21.1
462+
463+ @raises: Exception if dry_run is not supported
464+ """
465+ url = self .connection_info .conjur_url
466+
467+ # Check if it's Conjur Cloud
468+ if self ._is_conjur_cloud_url (url ):
469+ raise Exception ("dry_run is not supported in Secrets Manager SaaS" )
470+
471+ # Check server version
472+ try :
473+ server_version = await self .server_version ()
474+ min_version = "1.21.1"
475+
476+ if self ._is_version_less_than (server_version , min_version ):
477+ raise Exception (f"dry_run requires Conjur server version { min_version } or higher, but server version is { server_version } " )
478+ except Exception as err :
479+ # If we can't get the version, we should still raise an error
480+ # but include the original error message
481+ error_msg = str (err )
482+ raise Exception (f"Unable to validate dry_run support: { error_msg } " ) from err
483+
484+
353485 async def change_personal_password (
354486 self , logged_in_user : str , current_password : str ,
355487 new_password : str ) -> str :
0 commit comments