Skip to content

Commit e368c1b

Browse files
gl-johnsonGitHub Enterprise
authored andcommitted
Merge pull request #33 from Conjur-Enterprise/dry-run
CNJR-11338: Added dry-run parameter on policy methods
2 parents 4a187ca + f581599 commit e368c1b

File tree

11 files changed

+591
-45
lines changed

11 files changed

+591
-45
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased]
88

9+
## [0.1.9] - 2025-12-31
10+
11+
### Added
12+
- Support dry_run parameter for policy load methods, based on [PR #51](https://github.com/cyberark/conjur-api-python/pull/51). (CNJR-11338)
13+
914
## [0.1.8] - 2025-11-07
1015

1116
### Changed

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,22 +200,24 @@ Sets a variable to a specific value based on its ID.
200200

201201
Note: Policy to create the variable must have already been loaded, otherwise you will get a 404 error during invocation.
202202

203-
#### `load_policy_file(policy_name, policy_file)`
203+
#### `load_policy_file(policy_name, policy_file, dry_run=False)`
204204

205205
Applies a file-based YAML to a named policy. This method only supports additive changes. Result is a dictionary object
206-
constructed from the returned JSON data.
206+
constructed from the returned JSON data. If `dry_run` is set to `True`, the policy will not be applied and the result will
207+
be a dictionary representing the changes that would be applied.
207208

208-
#### `replace_policy_file(policy_name, policy_file)`
209+
#### `replace_policy_file(policy_name, policy_file, dry_run=False)`
209210

210211
Replaces a named policy with one from the provided file. This is usually a destructive invocation. Result is a
211-
dictionary object constructed from the returned JSON data.
212+
dictionary object constructed from the returned JSON data. If `dry_run` is set to `True`, the policy will not be applied
213+
and the result will be a dictionary representing the changes that would be applied.
212214

213-
#### `update_policy_file(policy_name, policy_file)`
215+
#### `update_policy_file(policy_name, policy_file, dry_run=False)`
214216

215217
Modifies an existing Secrets Manager policy. Data may be explicitly deleted using the `!delete`, `!revoke`, and `!deny`
216-
statements. Unlike
217-
"replace" mode, no data is ever implicitly deleted. Result is a dictionary object constructed from the returned JSON
218-
data.
218+
statements. Unlike "replace" mode, no data is ever implicitly deleted. Result is a dictionary object constructed from the
219+
returned JSON data. If `dry_run` is set to `True`, the policy will not be applied and the result will be a dictionary
220+
representing the changes that would be applied.
219221

220222
#### `list(list_constraints)`
221223

ci/test/Dockerfile.test

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,15 @@ RUN apt-get update && \
2020
RUN mkdir -p $INSTALL_DIR
2121
WORKDIR $INSTALL_DIR
2222

23-
# Install Python 3.10.1 using pyenv, wheel and required libs
23+
# Install Python 3.14.x
24+
ENV PYTHON_VERSION=3.14
2425
ENV PYENV_ROOT="/root/.pyenv"
2526
ENV PATH="$PYENV_ROOT/bin:$PYENV_ROOT/shims:$PATH"
2627

2728
RUN curl -L -s https://github.com/pyenv/pyenv-installer/raw/master/bin/pyenv-installer | bash \
2829
&& eval "$(pyenv init --path)" \
29-
&& env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.10.1 \
30-
&& pyenv global 3.10.1 \
30+
&& env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install $PYTHON_VERSION \
31+
&& pyenv global $PYTHON_VERSION \
3132
&& pip install wheel
3233

3334
# By putting this COPY command after we install Python, Docker will cache the Python installation layer,

conjur_api/client.py

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
# Builtins
1111
import json
1212
import logging
13+
import re
1314
from typing import Optional, Tuple
1415

16+
from packaging.version import Version
17+
1518
from conjur_api.errors.errors import ResourceNotFoundException, MissingRequiredParameterException, HttpStatusError
1619
from conjur_api.http.api import Api
1720
from conjur_api.interface.authentication_strategy_interface import AuthenticationStrategyInterface
@@ -27,6 +30,25 @@
2730

2831
logger = 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
3254
class 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:

conjur_api/http/api.py

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
# Builtins
99
import logging
10+
import re
1011
from datetime import datetime
1112
from typing import Optional, Tuple
1213
from urllib import parse
@@ -488,7 +489,7 @@ async def set_variable(self, variable_id: str, value: str) -> str:
488489

489490
async def _load_policy_file(
490491
self, policy_id: str, policy_file: str,
491-
http_verb: HttpVerb) -> dict:
492+
http_verb: HttpVerb, dry_run: bool) -> dict:
492493
"""
493494
This method is used to load, replace or update a file-based policy into the desired
494495
name.
@@ -505,32 +506,37 @@ async def _load_policy_file(
505506
if api_token is None:
506507
raise MissingApiTokenException()
507508

509+
query = {}
510+
if dry_run:
511+
query = { 'dryRun': 'true' }
512+
508513
response = await invoke_endpoint(http_verb, ConjurEndpoint.POLICIES, params,
509514
policy_data, api_token=api_token,
510515
ssl_verification_metadata=self.ssl_verification_data,
516+
query=query,
511517
proxy_params=self._connection_info.proxy_params)
512518
return response.json
513519

514-
async def load_policy_file(self, policy_id: str, policy_file: str) -> dict:
520+
async def load_policy_file(self, policy_id: str, policy_file: str, dry_run: bool) -> dict:
515521
"""
516522
This method is used to load a file-based policy into the desired
517523
name.
518524
"""
519-
return await self._load_policy_file(policy_id, policy_file, HttpVerb.POST)
525+
return await self._load_policy_file(policy_id, policy_file, HttpVerb.POST, dry_run)
520526

521-
async def replace_policy_file(self, policy_id: str, policy_file: str) -> dict:
527+
async def replace_policy_file(self, policy_id: str, policy_file: str, dry_run: bool) -> dict:
522528
"""
523529
This method is used to replace a file-based policy into the desired
524530
policy ID.
525531
"""
526-
return await self._load_policy_file(policy_id, policy_file, HttpVerb.PUT)
532+
return await self._load_policy_file(policy_id, policy_file, HttpVerb.PUT, dry_run)
527533

528-
async def update_policy_file(self, policy_id: str, policy_file: str) -> dict:
534+
async def update_policy_file(self, policy_id: str, policy_file: str, dry_run: bool) -> dict:
529535
"""
530536
This method is used to update a file-based policy into the desired
531537
policy ID.
532538
"""
533-
return await self._load_policy_file(policy_id, policy_file, HttpVerb.PATCH)
539+
return await self._load_policy_file(policy_id, policy_file, HttpVerb.PATCH, dry_run)
534540

535541
async def rotate_other_api_key(self, resource: Resource) -> str:
536542
"""
@@ -620,6 +626,35 @@ async def get_server_info(self):
620626
ssl_verification_metadata=self.ssl_verification_data,
621627
proxy_params=self._connection_info.proxy_params)
622628

629+
async def get_server_version_from_root(self) -> str:
630+
"""
631+
Retrieves the Conjur server version from the root endpoint (Conjur OSS).
632+
The root endpoint returns HTML, so we parse it to extract the version.
633+
@return: Server version string
634+
"""
635+
params = {
636+
'url': self._url
637+
}
638+
response = await invoke_endpoint(HttpVerb.GET,
639+
ConjurEndpoint.ROOT,
640+
params,
641+
ssl_verification_metadata=self.ssl_verification_data,
642+
proxy_params=self._connection_info.proxy_params)
643+
# The root endpoint returns HTML with version in format: "Version X.Y.Z" or "Version X.Y.Z-build"
644+
# Extract version using regex pattern
645+
html_text = response.text
646+
# Pattern matches: Version followed by space and version number (e.g., "Version 1.24.0-1049")
647+
version_pattern = r'Version\s+(\d+\.\d+\.\d+(?:-\d+)?)'
648+
match = re.search(version_pattern, html_text, re.IGNORECASE)
649+
if match:
650+
return match.group(1)
651+
# Fallback: try to find any semver pattern in the HTML
652+
semver_pattern = r'(\d+\.\d+\.\d+(?:-\d+)?)'
653+
match = re.search(semver_pattern, html_text)
654+
if match:
655+
return match.group(1)
656+
raise Exception("Unable to extract version from root endpoint HTML response")
657+
623658
async def whoami(self) -> dict:
624659
"""
625660
This method provides dictionary of information about the user making an API request.

conjur_api/http/endpoints.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ConjurEndpoint(Enum):
2424
LOGIN = "{url}/authn/{account}/login"
2525
LOGIN_LDAP = "{url}/authn-ldap/{service_id}/{account}/login"
2626
INFO = "{url}/info"
27+
ROOT = "{url}/"
2728
POLICIES = "{url}/policies/{account}/policy/{identifier}"
2829
BATCH_SECRETS = "{url}/secrets"
2930
SECRETS = "{url}/secrets/{account}/{kind}/{identifier}"

conjur_api/utils/decorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,18 @@ def allow_sync_invocation():
1717
"""
1818

1919
def allow_sync_mode(func):
20-
def wrapper(self, *args):
20+
def wrapper(self, *args, **kwargs):
2121
should_run_async = getattr(self, "async_mode")
2222
should_run_async |= func.__name__.startswith("_") # omit private functions
2323
if should_run_async: # Function should remain async
24-
return func(self, *args)
24+
return func(self, *args, **kwargs)
2525
loop = _get_event_loop()
2626
if loop is not None and loop.is_running():
2727
logger.error(
2828
"Failed to run conjur_api %s function in sync mode "
2929
"because code is running inside event loop", func.__name__)
3030
raise SyncInvocationInsideEventLoopError()
31-
return asyncio.run(func(self, *args))
31+
return asyncio.run(func(self, *args, **kwargs))
3232

3333
return wrapper
3434

0 commit comments

Comments
 (0)