Skip to content

feat: added support cross app secret referencing #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion src/phase/utils/const.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re

__version__ = "2.1.0"
__version__ = "2.1.1"
__ph_version__ = "v1"


Expand Down
34 changes: 25 additions & 9 deletions src/phase/utils/secret_referencing.py
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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.
"""


Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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:
Expand Down
45 changes: 43 additions & 2 deletions tests/test_secret_referencing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}"
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