diff --git a/pyproject.toml b/pyproject.toml index a917c55..4f4ca99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "phase_dev" -version = "2.1.0" +version = "2.1.1" description = "Python SDK for Phase secrets manager" readme = "README.md" requires-python = ">=3.10" diff --git a/requirements.txt b/requirements.txt index 5afcd6a..daae70d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ certifi==2023.7.22 -cffi==1.15.1 +cffi==1.17.1 charset-normalizer==3.1.0 exceptiongroup==1.1.1 idna==3.4 @@ -9,6 +9,6 @@ pluggy==1.0.0 pycparser==2.21 PyNaCl==1.5.0 pytest==7.3.1 -requests==2.31.0 +requests==2.32.0 tomli==2.0.1 urllib3==2.2.2 diff --git a/src/phase/utils/const.py b/src/phase/utils/const.py index 95410f1..9d3c971 100644 --- a/src/phase/utils/const.py +++ b/src/phase/utils/const.py @@ -1,7 +1,7 @@ import os import re -__version__ = "2.1.0" +__version__ = "2.1.1" __ph_version__ = "v1" diff --git a/src/phase/utils/secret_referencing.py b/src/phase/utils/secret_referencing.py index cb7fb12..2242be2 100644 --- a/src/phase/utils/secret_referencing.py +++ b/src/phase/utils/secret_referencing.py @@ -1,14 +1,14 @@ -import re from typing import Dict, List from .exceptions import EnvironmentNotFoundException from .const import SECRET_REF_REGEX +from .phase_io import Phase """ Secret Referencing Syntax: This documentation explains the syntax used for referencing secrets within the configuration. - Secrets can be referenced both locally (within the same environment) and across different environments, - with or without specifying a path. + Secrets can be referenced locally (within the same environment), across different environments, + and across different applications, with or without specifying a path. Syntax Patterns: @@ -40,8 +40,16 @@ - Secret Key: `STRIPE_KEY` - Description: References a secret named `STRIPE_KEY` located at `/backend/payments/` in the current environment. + 5. Cross-Application Reference: + Syntax: `${backend_api::production./frontend/SECRET_KEY}` + - Application: Different application (e.g., `backend_api`). + - Environment: Different environment (e.g., `production`). + - Path: Specifies a path within the environment (`/frontend/`). + - Secret Key: `SECRET_KEY` + - Description: References a secret named `SECRET_KEY` located at `/frontend/` in the `production` environment of the `backend_api` application. + Note: - The syntax allows for flexible secret management, enabling both straightforward local references and more complex cross-environment references. + The syntax allows for flexible secret management, enabling local references, cross-environment references, and cross-application references. """ @@ -74,12 +82,13 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st """ Resolves a single secret reference to its actual value by fetching it from the specified environment. - The function supports both local and cross-environment secret references, allowing for flexible secret management. + The function supports local, cross-environment, and cross-application secret references, allowing for flexible secret management. Local references are identified by the absence of a dot '.' in the reference string, implying the current environment. Cross-environment references include an environment name, separated by a dot from the rest of the path. + Cross-application references use '::' to separate the application name from the rest of the reference. Args: - ref (str): The secret reference string, which could be a local or cross-environment reference. + ref (str): The secret reference string, which could be a local, cross-environment, or cross-application reference. secrets_dict (Dict[str, Dict[str, Dict[str, str]]]): A dictionary containing known secrets. phase ('Phase'): An instance of the Phase class to fetch secrets. current_application_name (str): The name of the current application. @@ -88,10 +97,17 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st Returns: str: The resolved secret value or the original reference if not resolved. """ + original_ref = ref # Store the original reference + app_name = current_application_name env_name = current_env_name path = "/" # Default root path key_name = ref + # Check if this is a cross-application reference + if "::" in ref: + parts = ref.split("::", 1) + app_name, ref = parts[0], parts[1] + # Parse the reference to identify environment, path, and secret key. if "." in ref: # Cross-environment references parts = ref.split(".", 1) @@ -112,15 +128,15 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st return secrets_dict[env_name]['/'][key_name] # If the secret is not found in secrets_dict, try to fetch it from Phase - fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) + fetched_secrets = phase.get(env_name=env_name, app_name=app_name, keys=[key_name], path=path) for secret in fetched_secrets: if secret["key"] == key_name: return secret["value"] except EnvironmentNotFoundException: pass - # Return the reference as is if not resolved - return f"${{{ref}}}" + # Return the original secret value as is if not resolved + return f"${{{original_ref}}}" def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: diff --git a/tests/test_secret_referencing.py b/tests/test_secret_referencing.py index efd6a95..9de28e0 100644 --- a/tests/test_secret_referencing.py +++ b/tests/test_secret_referencing.py @@ -29,8 +29,12 @@ # Mock Phase class class MockPhase: def get(self, env_name, app_name, keys, path): - if env_name == "prod" and path == "/frontend": + if env_name == "prod" and path == "/frontend" and app_name == "test_app": return [{"key": "SECRET_KEY", "value": "prod_secret_value"}] + if env_name == "production" and path == "/" and app_name == "backend_api": + return [{"key": "API_KEY", "value": "backend_api_key"}] + if env_name == "staging" and path == "/auth" and app_name == "auth_service": + return [{"key": "AUTH_TOKEN", "value": "auth_service_token"}] raise EnvironmentNotFoundException(env_name=env_name) @pytest.fixture @@ -115,4 +119,41 @@ def test_resolve_local_reference_missing_path(phase, current_application_name, c def test_resolve_invalid_reference_format(phase, current_application_name, current_env_name): ref = "invalid_format" resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) - assert resolved_value == "${invalid_format}" \ No newline at end of file + assert resolved_value == "${invalid_format}" + +# Tests for Cross-Application References +def test_resolve_cross_app_reference(phase, current_application_name, current_env_name): + ref = "backend_api::production.API_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "backend_api_key" + +def test_resolve_cross_app_reference_with_path(phase, current_application_name, current_env_name): + ref = "auth_service::staging./auth/AUTH_TOKEN" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "auth_service_token" + +def test_resolve_missing_cross_app_key(phase, current_application_name, current_env_name): + ref = "another_app::dev.MISSING_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${another_app::dev.MISSING_KEY}" + +def test_resolve_all_secrets_with_cross_app(phase, current_application_name, current_env_name): + value = "Use this key: ${KEY}, this cross-app key: ${backend_api::production.API_KEY}, and this path key: ${/backend/payments/STRIPE_KEY}" + all_secrets = [ + {"environment": "current", "path": "/", "key": "KEY", "value": "value1"}, + {"environment": "current", "path": "/backend/payments", "key": "STRIPE_KEY", "value": "stripe_value"} + ] + resolved_value = resolve_all_secrets(value, all_secrets, phase, current_application_name, current_env_name) + expected_value = "Use this key: value1, this cross-app key: backend_api_key, and this path key: stripe_value" + assert resolved_value == expected_value + +# Complex Case: Mixed references including cross-app with missing values +def test_resolve_mixed_references_with_cross_app(phase, current_application_name, current_env_name): + value = "Local: ${KEY}, Cross-Env: ${staging.DEBUG}, Cross-App: ${backend_api::production.API_KEY}, Missing Cross-App: ${missing_app::prod.KEY}" + all_secrets = [ + {"environment": "current", "path": "/", "key": "KEY", "value": "value1"}, + {"environment": "staging", "path": "/", "key": "DEBUG", "value": "staging_debug_value"} + ] + resolved_value = resolve_all_secrets(value, all_secrets, phase, current_application_name, current_env_name) + expected_value = "Local: value1, Cross-Env: staging_debug_value, Cross-App: backend_api_key, Missing Cross-App: ${missing_app::prod.KEY}" + assert resolved_value == expected_value \ No newline at end of file