Skip to content
Open
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
13 changes: 11 additions & 2 deletions operations-manager/python/opi/forms/editables/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ def write(self, value: Any, context_data: dict[str, Any] | None = None) -> dict[
result = self._write_as_string(value) if self.write_as == "string" else self._write_as_dict(value)
if result and self.write_as == "string" and isinstance(result, str):
result = self._maybe_encrypt(result, context_data)
elif result and self.write_as == "dict" and isinstance(result, dict):
# Aliases: encrypt each value independently (values may hold secrets).
result = {k: self._maybe_encrypt(str(v), context_data) for k, v in result.items()}
logger.info(
"[KeyValueConverter.write] result type=%s, result=%r",
type(result).__name__ if result is not None else "None",
Expand Down Expand Up @@ -398,7 +401,13 @@ def _parse_env_text(text: str) -> dict[str, str]:

@staticmethod
def _maybe_decrypt(value: Any, context_data: dict[str, Any] | None) -> Any:
"""Decrypt AGE-encrypted value using the project's private key."""
"""Decrypt AGE-encrypted value using the project's private key.

Aliases are stored as a dict of ``name -> value``; each value may be
AGE-encrypted independently, so decrypt them per-entry.
"""
if isinstance(value, dict):
return {k: KeyValueConverter._maybe_decrypt(v, context_data) for k, v in value.items()}
if not isinstance(value, str) or "BEGIN AGE ENCRYPTED FILE" not in value:
return value
if not context_data:
Expand Down Expand Up @@ -436,7 +445,7 @@ def _maybe_encrypt(value: str, context_data: dict[str, Any] | None) -> str:
logger.debug("[KeyValueConverter] No project AGE public key, skipping encryption")
return value
encrypted = encrypt_age_content_sync(value, public_key)
logger.debug("[KeyValueConverter] Encrypted user-env-vars with project AGE key")
logger.debug("[KeyValueConverter] Encrypted value with project AGE key")
return LiteralScalarString(encrypted)
except Exception:
logger.warning("[KeyValueConverter] AGE encryption failed, returning plain value", exc_info=True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from opi.forms.editables.generators import (
AGEKeyPairGenerator,
AttachmentStagingResolveGenerator,
ComponentAliasesEncryptGenerator,
EncryptedAPIKeyGenerator,
EncryptedPrivateKeyGenerator,
ProjectNameGenerator,
Expand Down Expand Up @@ -45,6 +46,11 @@
generator=UserEnvVarsEncryptGenerator(),
)

ALIASES_ENCRYPT_GEN_EDITABLE = Editable(
yaml_path="_generated/aliases-encrypted",
generator=ComponentAliasesEncryptGenerator(),
)

ATTACHMENTS_RESOLVE_GEN_EDITABLE = Editable(
yaml_path="_generated/attachments-resolved",
generator=AttachmentStagingResolveGenerator(),
Expand All @@ -56,5 +62,6 @@
AGE_PRIVATE_KEY_GEN_EDITABLE,
API_KEY_GEN_EDITABLE,
USER_ENV_VARS_ENCRYPT_GEN_EDITABLE,
ALIASES_ENCRYPT_GEN_EDITABLE,
ATTACHMENTS_RESOLVE_GEN_EDITABLE,
]
40 changes: 40 additions & 0 deletions operations-manager/python/opi/forms/editables/generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,3 +213,43 @@ def generate(self, yaml_data: dict[str, Any]) -> Any:
)

return True


class ComponentAliasesEncryptGenerator:
"""Encrypt each component alias value with the project's AGE public key.

Aliases are stored as a ``name -> value`` map; values may hold secrets
(e.g. a password), so each value is encrypted independently while the
alias names stay readable. Skips values that are already AGE-encrypted.

Must run after ``AGEKeyPairGenerator`` so the project public key exists.
Uses a ``_generated`` path - the return value is discarded during cleanup.
"""

def generate(self, yaml_data: dict[str, Any]) -> Any:
from ruamel.yaml.scalarstring import LiteralScalarString

from opi.utils.age import encrypt_age_content_sync

public_key = yaml_data.get("config", {}).get("age-public-key")
if not public_key:
logger.debug("No project public key available, skipping aliases encryption")
return True

for component in yaml_data.get("components", []):
if not isinstance(component, dict):
continue
aliases = component.get("aliases")
if not isinstance(aliases, dict):
continue
for alias_name, alias_value in aliases.items():
if not isinstance(alias_value, str) or "BEGIN AGE ENCRYPTED FILE" in alias_value:
continue
aliases[alias_name] = LiteralScalarString(encrypt_age_content_sync(alias_value, public_key))
logger.debug(
"Encrypted alias '%s' for component %s",
alias_name,
component.get("name", "unknown"),
)

return True
13 changes: 13 additions & 0 deletions operations-manager/python/opi/manager/project_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
encrypt_age_content,
get_decoded_project_private_key,
get_project_public_key,
is_age_encrypted,
)
from opi.utils.env_vars import detect_circular_references, extract_variable_references, substitute_variables

Expand Down Expand Up @@ -984,6 +985,11 @@ async def _collect_deployment_aliases(self, deployment_name: str) -> dict[str, d
"secret": {},
}

# Alias values may hold secrets (e.g. a password) and are stored AGE-encrypted
# like user-env-vars. Decrypt lazily below, only when a value is encrypted, so
# projects without an AGE key and existing plaintext aliases keep working.
project_private_key: str | None = None

# Scan all components
components = deployment.get("components", [])
for component in components:
Expand All @@ -1007,6 +1013,13 @@ async def _collect_deployment_aliases(self, deployment_name: str) -> dict[str, d
)
continue

# Decrypt AGE-encrypted alias values before categorization/substitution.
# Plaintext values pass through unchanged (backward compatible).
if is_age_encrypted(alias_template):
if project_private_key is None:
project_private_key = await get_decoded_project_private_key(project_data)
alias_template = await decrypt_age_content(alias_template, project_private_key)

try:
# Determine which service and source type this alias belongs to
service_category, source_type = self._categorize_alias(alias_name, alias_template)
Expand Down
12 changes: 11 additions & 1 deletion operations-manager/python/opi/utils/project_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,10 +188,20 @@ async def build_component_config(
encrypted_env_vars = await encrypt_age_content(env_vars, public_key)
component_config["user-env-vars"] = LiteralScalarString(encrypted_env_vars)

# Add aliases if provided (no encryption needed - they reference system variables)
# Add aliases if provided. Alias values may hold secrets (e.g. a password), so
# encrypt each value with the project AGE key. Names stay readable. Plaintext
# values are kept as-is when no public key is available (backward compatible).
if aliases:
aliases_dict = parse_aliases(aliases)
if aliases_dict:
if public_key:
for alias_name, alias_value in aliases_dict.items():
if isinstance(alias_value, str) and "BEGIN AGE ENCRYPTED FILE" not in alias_value:
aliases_dict[alias_name] = LiteralScalarString(
await encrypt_age_content(alias_value, public_key)
)
else:
logger.warning("Could not encrypt aliases for component '%s': no AGE public key available", name)
component_config["aliases"] = aliases_dict

return component_config
Expand Down
5 changes: 5 additions & 0 deletions operations-manager/python/opi/web/router_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -2081,6 +2081,11 @@ def _literalize(d: dict, key: str) -> None:
for comp in data.get("components", []):
if isinstance(comp, dict):
_literalize(comp, "user-env-vars")
# Aliases are a name -> value map; values may be AGE-encrypted.
aliases = comp.get("aliases")
if isinstance(aliases, dict):
for alias_name in aliases:
_literalize(aliases, alias_name)

# Deployment-component-level user-env-vars (edit/add flows)
for dep in data.get("deployments", []):
Expand Down
105 changes: 100 additions & 5 deletions operations-manager/python/tests/test_editables_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
ServiceListConverter,
TruncateConverter,
)
from opi.forms.editables.generators import UserEnvVarsEncryptGenerator
from opi.forms.editables.generators import ComponentAliasesEncryptGenerator, UserEnvVarsEncryptGenerator
from opi.forms.editables.validators import KeyValueValidator

FAKE_AGE_ENCRYPTED = "-----BEGIN AGE ENCRYPTED FILE-----\nencrypted\n-----END AGE ENCRYPTED FILE-----"
Expand Down Expand Up @@ -393,16 +393,53 @@ def test_write_string_without_public_key_returns_plain(self):
result = conv.write("SECRET=value", context_data={"config": {}})
assert result == "SECRET=value"

def test_write_dict_mode_does_not_encrypt(self):
"""write_as='dict' mode should never attempt encryption."""
def test_write_dict_mode_encrypts_each_value(self):
"""write_as='dict' (aliases) must AGE-encrypt each value, keeping names readable."""
conv = KeyValueConverter(fmt="env", write_as="dict")
yaml_data = {"config": {"age-public-key": FAKE_PUBLIC_KEY}}

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
with patch("opi.utils.age.encrypt_age_content_sync", return_value=FAKE_AGE_ENCRYPTED) as mock:
result = conv.write("KEY=value", context_data=yaml_data)

mock.assert_not_called()
mock.assert_called_once_with("value", FAKE_PUBLIC_KEY)
assert isinstance(result, dict)
assert "KEY" in result
assert "BEGIN AGE ENCRYPTED FILE" in str(result["KEY"])

def test_write_dict_mode_skips_already_encrypted(self):
"""Already-encrypted alias values must not be double-encrypted."""
conv = KeyValueConverter(fmt="env", write_as="dict")
yaml_data = {"config": {"age-public-key": FAKE_PUBLIC_KEY}}

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
result = conv.write({"KEY": FAKE_AGE_ENCRYPTED}, context_data=yaml_data)

mock.assert_not_called()
assert result == {"KEY": FAKE_AGE_ENCRYPTED}

def test_write_dict_mode_without_public_key_returns_plain(self):
"""Without a project public key, alias values are stored plain (backward compatible)."""
conv = KeyValueConverter(fmt="env", write_as="dict")

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
result = conv.write("KEY=value", context_data={"config": {}})

mock.assert_not_called()
assert result == {"KEY": "value"}

def test_read_dict_mode_decrypts_each_value(self):
"""Reading aliases must decrypt each AGE-encrypted value for editor display."""
conv = KeyValueConverter(fmt="env", write_as="dict")
stored = {"KEY": FAKE_AGE_ENCRYPTED, "PLAIN": "just-text"}

with (
patch("opi.utils.age.decrypt_age_content_sync", return_value="decrypted"),
patch("opi.forms.editables.converters.resolve_project_private_key", return_value="AGE-SECRET-KEY-1TEST"),
):
result = conv.read(stored, context_data={"config": {}})

assert "KEY=decrypted" in result
assert "PLAIN=just-text" in result


class TestUserEnvVarsEncryptGenerator:
Expand Down Expand Up @@ -468,6 +505,64 @@ def test_skips_when_no_public_key(self):
assert yaml_data["components"][0]["user-env-vars"] == "SECRET=value"


class TestComponentAliasesEncryptGenerator:
"""Verify that the generator encrypts each component alias value."""

def test_encrypts_plain_alias_values(self):
"""Plain-text alias values on components must be encrypted; names stay readable."""
yaml_data = {
"config": {"age-public-key": FAKE_PUBLIC_KEY},
"components": [
{"name": "frontend", "aliases": {"DB_PASS": "secret123", "SELF": "https://$PUBLIC_HOST"}},
],
}

with patch("opi.utils.age.encrypt_age_content_sync", return_value=FAKE_AGE_ENCRYPTED):
ComponentAliasesEncryptGenerator().generate(yaml_data)

aliases = yaml_data["components"][0]["aliases"]
assert set(aliases.keys()) == {"DB_PASS", "SELF"}
for value in aliases.values():
assert "BEGIN AGE ENCRYPTED FILE" in str(value)

def test_skips_already_encrypted(self):
"""Already-encrypted alias values must not be re-encrypted."""
yaml_data = {
"config": {"age-public-key": FAKE_PUBLIC_KEY},
"components": [{"name": "frontend", "aliases": {"DB_PASS": FAKE_AGE_ENCRYPTED}}],
}

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
ComponentAliasesEncryptGenerator().generate(yaml_data)

mock.assert_not_called()

def test_skips_when_no_public_key(self):
"""Without a project public key, generator should skip (not crash)."""
yaml_data = {
"config": {},
"components": [{"name": "frontend", "aliases": {"DB_PASS": "secret"}}],
}

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
ComponentAliasesEncryptGenerator().generate(yaml_data)

mock.assert_not_called()
assert yaml_data["components"][0]["aliases"]["DB_PASS"] == "secret"

def test_skips_components_without_aliases(self):
"""Components without an aliases map should be left alone."""
yaml_data = {
"config": {"age-public-key": FAKE_PUBLIC_KEY},
"components": [{"name": "frontend"}],
}

with patch("opi.utils.age.encrypt_age_content_sync") as mock:
ComponentAliasesEncryptGenerator().generate(yaml_data)

mock.assert_not_called()


class TestAGEEncryptConverter:
"""Verify that AGEEncryptConverter encrypts/decrypts field values."""

Expand Down
Loading