From bc645eee0ab164f28c5788af8f1208d6172610ef Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:30 +0530 Subject: [PATCH 01/12] feat: add queued sync status and trigger syncs for referencing environments --- backend/api/models.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/backend/api/models.py b/backend/api/models.py index 9f99a19e3..57aa665ff 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.conf import settings from api.services import Providers, ServiceConfig -from api.tasks.syncing import trigger_sync_tasks +from api.tasks.syncing import trigger_sync_tasks, trigger_syncs_for_referencing_envs from backend.quotas import ( can_add_account, can_add_app, @@ -402,6 +402,10 @@ def save(self, *args, **kwargs): if env_sync.is_active ] + # Trigger syncs for other environments whose secrets + # reference this one (cross-env and cross-app references) + trigger_syncs_for_referencing_envs(self) + class EnvironmentKey(models.Model): id = models.TextField(default=uuid4, primary_key=True, editable=False) @@ -470,6 +474,7 @@ class ProviderCredentials(models.Model): class EnvironmentSync(models.Model): + QUEUED = "queued" IN_PROGRESS = "in_progress" COMPLETED = "completed" CANCELLED = "cancelled" @@ -477,6 +482,7 @@ class EnvironmentSync(models.Model): FAILED = "failed" STATUS_OPTIONS = [ + (QUEUED, "Queued"), (IN_PROGRESS, "In progress"), (COMPLETED, "Completed"), (CANCELLED, "cancelled"), @@ -501,7 +507,7 @@ class EnvironmentSync(models.Model): status = models.CharField( max_length=16, choices=STATUS_OPTIONS, - default=IN_PROGRESS, + default=QUEUED, ) @@ -512,7 +518,7 @@ class EnvironmentSyncEvent(models.Model): status = models.CharField( max_length=16, choices=EnvironmentSync.STATUS_OPTIONS, - default=EnvironmentSync.IN_PROGRESS, + default=EnvironmentSync.QUEUED, ) created_at = models.DateTimeField(auto_now_add=True, blank=True, null=True) completed_at = models.DateTimeField(blank=True, null=True) From 6809c825956c3c8e14cb719bc8876b41deaa34fb Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:35 +0530 Subject: [PATCH 02/12] feat: refactor sync dispatch, add queued lifecycle, and trigger syncs for cross-references --- backend/api/tasks/syncing.py | 201 ++++++++++++++++------------------- 1 file changed, 94 insertions(+), 107 deletions(-) diff --git a/backend/api/tasks/syncing.py b/backend/api/tasks/syncing.py index 6338f69b0..27a48e427 100644 --- a/backend/api/tasks/syncing.py +++ b/backend/api/tasks/syncing.py @@ -58,114 +58,37 @@ def trigger_sync_tasks(env_sync): cancel_sync_tasks(env_sync) # cancel any running or queued jobs for this sync - if env_sync.service == ServiceConfig.CLOUDFLARE_PAGES["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_cloudflare_pages_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.CLOUDFLARE_WORKERS["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_cloudflare_workers_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.AWS_SECRETS_MANAGER["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_aws_sm_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITHUB_ACTIONS["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_github_actions_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITHUB_DEPENDABOT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_github_dependabot_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.HASHICORP_VAULT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_vault_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.HASHICORP_NOMAD["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_nomad_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.GITLAB_CI["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_gitlab_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.RAILWAY["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_railway_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.VERCEL["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() - - job = perform_vercel_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.RENDER["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS - env_sync.save() + SERVICE_DISPATCH = { + ServiceConfig.CLOUDFLARE_PAGES["id"]: perform_cloudflare_pages_sync, + ServiceConfig.CLOUDFLARE_WORKERS["id"]: perform_cloudflare_workers_sync, + ServiceConfig.AWS_SECRETS_MANAGER["id"]: perform_aws_sm_sync, + ServiceConfig.GITHUB_ACTIONS["id"]: perform_github_actions_sync, + ServiceConfig.GITHUB_DEPENDABOT["id"]: perform_github_dependabot_sync, + ServiceConfig.HASHICORP_VAULT["id"]: perform_vault_sync, + ServiceConfig.HASHICORP_NOMAD["id"]: perform_nomad_sync, + ServiceConfig.GITLAB_CI["id"]: perform_gitlab_sync, + ServiceConfig.RAILWAY["id"]: perform_railway_sync, + ServiceConfig.VERCEL["id"]: perform_vercel_sync, + ServiceConfig.RENDER["id"]: perform_render_service_sync, + ServiceConfig.AZURE_KEY_VAULT["id"]: perform_azure_kv_sync, + } + + sync_func = SERVICE_DISPATCH.get(env_sync.service) + if sync_func is None: + return + + env_sync.status = EnvironmentSync.QUEUED + env_sync.save() - job = perform_render_service_sync.delay(env_sync) + try: + job = sync_func.delay(env_sync) job_id = job.get_id() - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - - elif env_sync.service == ServiceConfig.AZURE_KEY_VAULT["id"]: - env_sync.status = EnvironmentSync.IN_PROGRESS + except Exception as e: + logger.error(f"Failed to dispatch sync job for {env_sync.id}: {e}") + env_sync.status = EnvironmentSync.FAILED env_sync.save() - job = perform_azure_kv_sync.delay(env_sync) - job_id = job.get_id() - - EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync) - # try and cancel running or queued jobs for this sync def cancel_sync_tasks(env_sync): @@ -175,7 +98,8 @@ def cancel_sync_tasks(env_sync): EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent") for sync_event in EnvironmentSyncEvent.objects.filter( - env_sync=env_sync, status=EnvironmentSync.IN_PROGRESS + env_sync=env_sync, + status__in=[EnvironmentSync.IN_PROGRESS, EnvironmentSync.QUEUED], ): try: job = Job.fetch(sync_event.id, connection=get_queue("default").connection) @@ -202,10 +126,14 @@ def handle_sync_event(environment_sync, sync_function, *args, **kwargs): .first() ) - try: - EnvironmentSync = apps.get_model("api", "EnvironmentSync") - EnvironmentSyncEvent = apps.get_model("api", "EnvironmentSyncEvent") + # Mark as in-progress now that the worker has picked up the job + environment_sync.status = EnvironmentSync.IN_PROGRESS + environment_sync.save() + if sync_event: + sync_event.status = EnvironmentSync.IN_PROGRESS + sync_event.save() + try: secrets = get_environment_secrets( environment_sync.environment, environment_sync.path ) @@ -572,3 +500,62 @@ def perform_azure_kv_sync(environment_sync): credentials.get("client_secret"), vault_uri, ) + + +def trigger_syncs_for_referencing_envs(changed_env): + """ + Finds environments with active syncs whose secrets reference the changed + environment, and triggers those syncs to keep referenced values up to date. + + Called synchronously from Environment.save() so that sync status is set to + IN_PROGRESS immediately (the actual sync work is dispatched async by + trigger_sync_tasks). This ensures the UI reflects the pending sync right away. + + This handles the case where env B has a secret like ${staging.DB_HOST} + referencing env "staging" — when a secret in "staging" changes, env B's + syncs need to be triggered too. + """ + from api.utils.secrets import env_has_references_to + + EnvironmentSync = apps.get_model("api", "EnvironmentSync") + + org = changed_env.app.organisation + changed_env_name = changed_env.name + changed_app_name = changed_env.app.name + changed_app_id = changed_env.app_id + + # Find all active syncs in the org, excluding the changed environment + candidate_syncs = EnvironmentSync.objects.filter( + environment__app__organisation=org, + is_active=True, + deleted_at=None, + ).exclude( + environment=changed_env + ).select_related("environment", "environment__app") + + # Group syncs by environment to avoid redundant reference checks + env_syncs_map = {} + for sync in candidate_syncs: + env_id = sync.environment_id + if env_id not in env_syncs_map: + env_syncs_map[env_id] = [] + env_syncs_map[env_id].append(sync) + + if not env_syncs_map: + return + + for env_id, syncs in env_syncs_map.items(): + try: + if env_has_references_to( + env_id, changed_env_name, changed_app_name, changed_app_id + ): + logger.info( + f"Environment {env_id} references changed environment " + f"{changed_env.id}, triggering syncs" + ) + for sync in syncs: + trigger_sync_tasks(sync) + except Exception as e: + logger.warning( + f"Failed to check references for environment {env_id}: {e}" + ) From d430de7acaa9a022107f5b26e5660abb19564490 Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:39 +0530 Subject: [PATCH 03/12] feat: add env_has_references_to for detecting cross-env and cross-app secret references --- backend/api/utils/secrets.py | 73 ++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/backend/api/utils/secrets.py b/backend/api/utils/secrets.py index 34947d4a5..bf435cb89 100644 --- a/backend/api/utils/secrets.py +++ b/backend/api/utils/secrets.py @@ -488,3 +488,76 @@ def decrypt_secret_value( raise SecretReferenceException("\n".join(unresolved_local_references)) return value + + +def env_has_references_to(source_env_id, target_env_name, target_app_name, target_app_id): + """ + Check if any secrets in the source environment contain references + to the target environment. + + Used to determine which syncs need to be triggered when a referenced + environment's secrets change. + + Args: + source_env_id: ID of the environment to check secrets in. + target_env_name: Name of the potentially-referenced environment. + target_app_name: Name of the app containing the target environment. + target_app_id: ID of the app containing the target environment. + + Returns: + bool: True if any secret in source_env references the target environment. + """ + Secret = apps.get_model("api", "Secret") + ServerEnvironmentKey = apps.get_model("api", "ServerEnvironmentKey") + Environment = apps.get_model("api", "Environment") + + try: + source_env = Environment.objects.select_related("app").get(id=source_env_id) + except Environment.DoesNotExist: + return False + + try: + server_env_key = ServerEnvironmentKey.objects.get( + environment_id=source_env_id + ) + except ServerEnvironmentKey.DoesNotExist: + return False + + pk, sk = get_server_keypair() + + try: + env_seed = decrypt_asymmetric(server_env_key.wrapped_seed, sk.hex(), pk.hex()) + env_pubkey, env_privkey = env_keypair(env_seed) + except Exception: + return False + + secrets = Secret.objects.filter( + environment_id=source_env_id, + deleted_at=None, + ) + + same_app = str(source_env.app_id) == str(target_app_id) + target_env_lower = target_env_name.lower() + target_app_lower = target_app_name.lower() + + for secret in secrets: + try: + value = decrypt_asymmetric(secret.value, env_privkey, env_pubkey) + + # Check cross-app references: ${APP::ENV.KEY} + for ref_app, ref_env, _ in CROSS_APP_ENV_PATTERN.findall(value): + if ( + ref_app.lower() == target_app_lower + and ref_env.lower() == target_env_lower + ): + return True + + # Check cross-env references: ${ENV.KEY} (only if same app) + if same_app: + for ref_env, _ in CROSS_ENV_PATTERN.findall(value): + if ref_env.lower() == target_env_lower: + return True + except Exception: + continue + + return False From 42bf984e28ca3855384e5e13fd72b5f08d2b0fdf Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:44 +0530 Subject: [PATCH 04/12] feat: add migration for queued sync status and default changes --- .../migrations/0120_add_queued_sync_status.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 backend/api/migrations/0120_add_queued_sync_status.py diff --git a/backend/api/migrations/0120_add_queued_sync_status.py b/backend/api/migrations/0120_add_queued_sync_status.py new file mode 100644 index 000000000..d82ece1ad --- /dev/null +++ b/backend/api/migrations/0120_add_queued_sync_status.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-03-25 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0119_secretevent_type_field'), + ] + + operations = [ + migrations.AlterField( + model_name='environmentsync', + name='status', + field=models.CharField(choices=[('queued', 'Queued'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('cancelled', 'cancelled'), ('timed_out', 'Timed out'), ('failed', 'Failed')], default='queued', max_length=16), + ), + migrations.AlterField( + model_name='environmentsyncevent', + name='status', + field=models.CharField(choices=[('queued', 'Queued'), ('in_progress', 'In progress'), ('completed', 'Completed'), ('cancelled', 'cancelled'), ('timed_out', 'Timed out'), ('failed', 'Failed')], default='queued', max_length=16), + ), + ] From 4a12726e3d806efb540806de17aeeafc0c9c31be Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:50 +0530 Subject: [PATCH 05/12] test: add tests for env_has_references_to and trigger_syncs_for_referencing_envs --- backend/tests/tasks/__init__.py | 0 backend/tests/tasks/test_syncing.py | 156 ++++++++++++++++ backend/tests/utils/test_secret.py | 278 ++++++++++++++++++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 backend/tests/tasks/__init__.py create mode 100644 backend/tests/tasks/test_syncing.py diff --git a/backend/tests/tasks/__init__.py b/backend/tests/tasks/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/tests/tasks/test_syncing.py b/backend/tests/tasks/test_syncing.py new file mode 100644 index 000000000..1d21d3079 --- /dev/null +++ b/backend/tests/tasks/test_syncing.py @@ -0,0 +1,156 @@ +import pytest +from unittest.mock import patch, MagicMock, call +from api.tasks.syncing import trigger_syncs_for_referencing_envs + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_with_references( + mock_get_model, mock_trigger_sync +): + """Test that syncs are triggered for environments whose secrets reference the changed env""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + candidate_sync = MagicMock() + candidate_sync.environment_id = "candidate-env-id" + candidate_sync.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + candidate_sync + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + with patch( + "api.utils.secrets.env_has_references_to", return_value=True + ) as mock_has_refs: + trigger_syncs_for_referencing_envs(changed_env) + + mock_has_refs.assert_called_once_with( + "candidate-env-id", "staging", "my-app", "app-1" + ) + + mock_trigger_sync.assert_called_once_with(candidate_sync) + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_no_references( + mock_get_model, mock_trigger_sync +): + """Test that syncs are NOT triggered when no references exist""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + candidate_sync = MagicMock() + candidate_sync.environment_id = "candidate-env-id" + candidate_sync.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + candidate_sync + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + with patch("api.utils.secrets.env_has_references_to", return_value=False): + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_not_called() + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_for_referencing_envs_no_candidate_syncs( + mock_get_model, mock_trigger_sync +): + """Test early return when no other active syncs exist in the org""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + MockEnvironmentSync = MagicMock() + # Empty queryset — no candidate syncs + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = ( + [] + ) + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_not_called() + + +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_multiple_envs_only_matching_triggered( + mock_get_model, mock_trigger_sync +): + """Test that only syncs for environments with references are triggered""" + changed_env = MagicMock() + changed_env.id = "changed-env-id" + changed_env.name = "staging" + changed_env.app.name = "my-app" + changed_env.app_id = "app-1" + changed_env.app.organisation = MagicMock() + + # Two candidate syncs on different environments + sync_with_ref = MagicMock() + sync_with_ref.environment_id = "env-with-ref" + sync_with_ref.environment = MagicMock() + + sync_without_ref = MagicMock() + sync_without_ref.environment_id = "env-without-ref" + sync_without_ref.environment = MagicMock() + + MockEnvironmentSync = MagicMock() + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ + sync_with_ref, + sync_without_ref, + ] + + def get_model_side_effect(app_label, model_name): + if model_name == "EnvironmentSync": + return MockEnvironmentSync + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + def has_refs_side_effect(source_env_id, *args): + return source_env_id == "env-with-ref" + + with patch( + "api.utils.secrets.env_has_references_to", side_effect=has_refs_side_effect + ): + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_called_once_with(sync_with_ref) diff --git a/backend/tests/utils/test_secret.py b/backend/tests/utils/test_secret.py index 15acc3d33..4a232d2be 100644 --- a/backend/tests/utils/test_secret.py +++ b/backend/tests/utils/test_secret.py @@ -8,6 +8,7 @@ normalize_path_string, decompose_path_and_key, decrypt_secret_value, + env_has_references_to, ) @@ -316,3 +317,280 @@ def test_decrypt_secret_value_ignores_railway_syntax( result = decrypt_secret_value(mock_secret) assert result == "Some value with ${{RAILWAY_REF}}" + + +# --- env_has_references_to tests --- + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_cross_env( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test detecting ${ENV.KEY} cross-env reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + # Mock models + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + # decrypt_asymmetric: first call for seed, second for secret value + mock_decrypt.side_effect = ["env_seed", "url=${staging.DB_HOST}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_cross_app( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test detecting ${APP::ENV.KEY} cross-app reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-2" # Different app + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${backend::production.API_KEY}"] + + result = env_has_references_to("env-2", "production", "backend", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_no_match( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that no reference is detected when values don't reference the target""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "just a plain value"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_no_sse( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that False is returned when source env has no ServerEnvironmentKey (no SSE)""" + mock_server_kp.return_value = (b"pk", b"sk") + + mock_env = MagicMock() + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.DoesNotExist = type("DoesNotExist", (Exception,), {}) + MockServerEnvKey.objects.get.side_effect = MockServerEnvKey.DoesNotExist() + + def get_model_side_effect(app_label, model_name): + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_case_insensitive( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that reference matching is case-insensitive""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${STAGING.DB_HOST}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is True + + +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_env_has_references_to_ignores_railway_syntax( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """Test that ${{...}} Railway-style syntax is not treated as a reference""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + + mock_env = MagicMock() + mock_env.app_id = "app-1" + + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = mock_env + MockEnvironment.DoesNotExist = Exception + + MockServerEnvKey = MagicMock() + MockServerEnvKey.objects.get.return_value = mock_server_env_key + MockServerEnvKey.DoesNotExist = Exception + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] + + def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret + if model_name == "ServerEnvironmentKey": + return MockServerEnvKey + if model_name == "Environment": + return MockEnvironment + return MagicMock() + + mock_get_model.side_effect = get_model_side_effect + + mock_decrypt.side_effect = ["env_seed", "url=${{staging.DB_HOST}}"] + + result = env_has_references_to("env-1", "staging", "my-app", "app-1") + assert result is False From 4f0502727e51b825fc226175672d9b6ade961e7b Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:54 +0530 Subject: [PATCH 06/12] chore: regenerate GraphQL schema and types with queued sync status --- frontend/apollo/graphql.ts | 18 ++++++++++++++++-- frontend/apollo/schema.graphql | 20 +++++++++++++++++++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index f00798e51..9a339ee9e 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -181,6 +181,8 @@ export enum ApiEnvironmentSyncEventStatusChoices { Failed = 'FAILED', /** In progress */ InProgress = 'IN_PROGRESS', + /** Queued */ + Queued = 'QUEUED', /** Timed out */ TimedOut = 'TIMED_OUT' } @@ -195,6 +197,8 @@ export enum ApiEnvironmentSyncStatusChoices { Failed = 'FAILED', /** In progress */ InProgress = 'IN_PROGRESS', + /** Queued */ + Queued = 'QUEUED', /** Timed out */ TimedOut = 'TIMED_OUT' } @@ -221,6 +225,16 @@ export enum ApiSecretEventEventTypeChoices { U = 'U' } +/** An enumeration. */ +export enum ApiSecretEventTypeChoices { + /** Config */ + Config = 'CONFIG', + /** Sealed */ + Sealed = 'SEALED', + /** Secret */ + Secret = 'SECRET' +} + /** An enumeration. */ export enum ApiSecretTypeChoices { /** Config */ @@ -2346,7 +2360,7 @@ export type SecretEventType = { serviceToken?: Maybe; tags: Array; timestamp: Scalars['DateTime']['output']; - type: ApiSecretTypeChoices; + type: ApiSecretEventTypeChoices; user?: Maybe; userAgent?: Maybe; value: Scalars['String']['output']; @@ -3810,7 +3824,7 @@ export type GetSecretHistoryQueryVariables = Exact<{ }>; -export type GetSecretHistoryQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, history?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, type: ApiSecretTypeChoices, path: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null, serviceToken?: { __typename?: 'ServiceTokenType', id: string, name: string } | null, serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, deletedAt?: any | null } | null } | null> | null } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; +export type GetSecretHistoryQuery = { __typename?: 'Query', secrets?: Array<{ __typename?: 'SecretType', id: string, history?: Array<{ __typename?: 'SecretEventType', id: string, key: string, value: string, type: ApiSecretEventTypeChoices, path: string, version: number, comment: string, timestamp: any, ipAddress?: string | null, userAgent?: string | null, eventType: ApiSecretEventEventTypeChoices, tags: Array<{ __typename?: 'SecretTagType', id: string, name: string, color: string }>, user?: { __typename?: 'OrganisationMemberType', email?: string | null, username?: string | null, fullName?: string | null, avatarUrl?: string | null } | null, serviceToken?: { __typename?: 'ServiceTokenType', id: string, name: string } | null, serviceAccount?: { __typename?: 'ServiceAccountType', id: string, name: string, deletedAt?: any | null } | null } | null> | null } | null> | null, environmentKeys?: Array<{ __typename?: 'EnvironmentKeyType', id: string, identityKey: string, wrappedSeed: string, wrappedSalt: string } | null> | null }; export type GetEnvSecretsKvQueryVariables = Exact<{ envId: Scalars['ID']['input']; diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 6b1227c41..342810876 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -269,7 +269,7 @@ type SecretEventType { version: Int! tags: [SecretTagType!]! comment: String! - type: ApiSecretTypeChoices! + type: ApiSecretEventTypeChoices! eventType: ApiSecretEventEventTypeChoices! timestamp: DateTime! ipAddress: String @@ -366,6 +366,18 @@ type AwsIamConfigType { stsEndpoint: String } +"""An enumeration.""" +enum ApiSecretEventTypeChoices { + """Secret""" + SECRET + + """Sealed""" + SEALED + + """Config""" + CONFIG +} + """An enumeration.""" enum ApiSecretEventEventTypeChoices { """Create""" @@ -550,6 +562,9 @@ type EnvironmentSyncType { """An enumeration.""" enum ApiEnvironmentSyncStatusChoices { + """Queued""" + QUEUED + """In progress""" IN_PROGRESS @@ -584,6 +599,9 @@ type EnvironmentSyncEventType { """An enumeration.""" enum ApiEnvironmentSyncEventStatusChoices { + """Queued""" + QUEUED + """In progress""" IN_PROGRESS From 6b4240dd1e41f5167db3eb5b931baa8d95a525ec Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:38:58 +0530 Subject: [PATCH 07/12] feat: handle queued sync status in frontend sync components --- frontend/components/syncing/EnvSyncStatus.tsx | 4 +++- frontend/components/syncing/SyncCard.tsx | 3 ++- frontend/components/syncing/SyncHistory.tsx | 3 ++- .../components/syncing/SyncStatusIndicator.tsx | 15 ++++++++++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/frontend/components/syncing/EnvSyncStatus.tsx b/frontend/components/syncing/EnvSyncStatus.tsx index 74529a651..2a67fdfa0 100644 --- a/frontend/components/syncing/EnvSyncStatus.tsx +++ b/frontend/components/syncing/EnvSyncStatus.tsx @@ -17,7 +17,9 @@ export const EnvSyncStatus = (props: { const syncStatus = () => { if ( syncs.some( - (sync: EnvironmentSyncType) => sync.status === ApiEnvironmentSyncStatusChoices.InProgress + (sync: EnvironmentSyncType) => + sync.status === ApiEnvironmentSyncStatusChoices.InProgress || + sync.status === ApiEnvironmentSyncStatusChoices.Queued ) ) return ApiEnvironmentSyncStatusChoices.InProgress diff --git a/frontend/components/syncing/SyncCard.tsx b/frontend/components/syncing/SyncCard.tsx index 9add770fc..806ffa433 100644 --- a/frontend/components/syncing/SyncCard.tsx +++ b/frontend/components/syncing/SyncCard.tsx @@ -72,7 +72,8 @@ export const SyncCard = (props: {
{sync.status && }
- {sync.status === ApiEnvironmentSyncStatusChoices.InProgress ? ( + {sync.status === ApiEnvironmentSyncStatusChoices.InProgress || + sync.status === ApiEnvironmentSyncStatusChoices.Queued ? (
) : (
diff --git a/frontend/components/syncing/SyncHistory.tsx b/frontend/components/syncing/SyncHistory.tsx index 708abffff..4891b56f1 100644 --- a/frontend/components/syncing/SyncHistory.tsx +++ b/frontend/components/syncing/SyncHistory.tsx @@ -75,7 +75,8 @@ const SyncLogRow = (props: { event: EnvironmentSyncEventType }) => { {event.completedAt && - event.status !== ApiEnvironmentSyncEventStatusChoices.InProgress && ( + event.status !== ApiEnvironmentSyncEventStatusChoices.InProgress && + event.status !== ApiEnvironmentSyncEventStatusChoices.Queued && (
{relativeTimeFromDates(new Date(event.completedAt))}
)} diff --git a/frontend/components/syncing/SyncStatusIndicator.tsx b/frontend/components/syncing/SyncStatusIndicator.tsx index 171c4d459..d0e82b296 100644 --- a/frontend/components/syncing/SyncStatusIndicator.tsx +++ b/frontend/components/syncing/SyncStatusIndicator.tsx @@ -2,7 +2,13 @@ import { ApiEnvironmentSyncEventStatusChoices, ApiEnvironmentSyncStatusChoices, } from '@/apollo/graphql' -import { FaCheckCircle, FaHourglassEnd, FaMinusCircle, FaTimesCircle } from 'react-icons/fa' +import { + FaCheckCircle, + FaClock, + FaHourglassEnd, + FaMinusCircle, + FaTimesCircle, +} from 'react-icons/fa' import Spinner from '../common/Spinner' export const SyncStatusIndicator = (props: { @@ -39,6 +45,13 @@ export const SyncStatusIndicator = (props: { {showLabel && 'Skipped'}
) + } else if (status === ApiEnvironmentSyncStatusChoices.Queued) { + return ( +
+ + {showLabel && 'Queued'} +
+ ) } else return (
From 28e4beeabb0d3243cbdb6c5dc8c5caa98412e47d Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 17:53:17 +0530 Subject: [PATCH 08/12] feat: preserve env ordering in sync status menu Signed-off-by: rohan --- frontend/apollo/gql.ts | 6 +++--- frontend/apollo/graphql.ts | 4 ++-- frontend/components/syncing/EnvSyncStatus.tsx | 8 +++++--- frontend/graphql/queries/syncing/getAppSyncStatus.gql | 1 + 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/apollo/gql.ts b/frontend/apollo/gql.ts index 56fd55adb..ef60a1b10 100644 --- a/frontend/apollo/gql.ts +++ b/frontend/apollo/gql.ts @@ -157,7 +157,7 @@ type Documents = { "query GetAzureKeyVaultSecrets($credentialId: ID!, $vaultUri: String!) {\n azureKvSecrets(credentialId: $credentialId, vaultUri: $vaultUri) {\n name\n updatedOn\n contentType\n }\n}": typeof types.GetAzureKeyVaultSecretsDocument, "query GetCfPages($credentialId: ID!) {\n cloudflarePagesProjects(credentialId: $credentialId) {\n name\n deploymentId\n environments\n }\n}": typeof types.GetCfPagesDocument, "query GetCfWorkers($credentialId: ID!) {\n cloudflareWorkers(credentialId: $credentialId) {\n name\n scriptId\n }\n}": typeof types.GetCfWorkersDocument, - "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": typeof types.GetAppSyncStatusDocument, + "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": typeof types.GetAppSyncStatusDocument, "query GetProviderList {\n providers {\n id\n name\n expectedCredentials\n optionalCredentials\n authScheme\n }\n serverPublicKey\n}": typeof types.GetProviderListDocument, "query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": typeof types.GetSavedCredentialsDocument, "query GetServerKey {\n serverPublicKey\n}": typeof types.GetServerKeyDocument, @@ -318,7 +318,7 @@ const documents: Documents = { "query GetAzureKeyVaultSecrets($credentialId: ID!, $vaultUri: String!) {\n azureKvSecrets(credentialId: $credentialId, vaultUri: $vaultUri) {\n name\n updatedOn\n contentType\n }\n}": types.GetAzureKeyVaultSecretsDocument, "query GetCfPages($credentialId: ID!) {\n cloudflarePagesProjects(credentialId: $credentialId) {\n name\n deploymentId\n environments\n }\n}": types.GetCfPagesDocument, "query GetCfWorkers($credentialId: ID!) {\n cloudflareWorkers(credentialId: $credentialId) {\n name\n scriptId\n }\n}": types.GetCfWorkersDocument, - "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": types.GetAppSyncStatusDocument, + "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}": types.GetAppSyncStatusDocument, "query GetProviderList {\n providers {\n id\n name\n expectedCredentials\n optionalCredentials\n authScheme\n }\n serverPublicKey\n}": types.GetProviderListDocument, "query GetSavedCredentials($orgId: ID!) {\n savedCredentials(orgId: $orgId) {\n id\n name\n credentials\n createdAt\n provider {\n id\n name\n expectedCredentials\n optionalCredentials\n }\n syncCount\n }\n}": types.GetSavedCredentialsDocument, "query GetServerKey {\n serverPublicKey\n}": types.GetServerKeyDocument, @@ -925,7 +925,7 @@ export function graphql(source: "query GetCfWorkers($credentialId: ID!) {\n clo /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"): (typeof documents)["query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"]; +export function graphql(source: "query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"): (typeof documents)["query GetAppSyncStatus($appId: ID!) {\n sseEnabled(appId: $appId)\n syncs(appId: $appId) {\n id\n environment {\n id\n name\n envType\n index\n app {\n id\n name\n }\n }\n path\n serviceInfo {\n id\n name\n provider {\n id\n }\n }\n options\n isActive\n lastSync\n status\n authentication {\n id\n name\n credentials\n }\n createdAt\n history {\n id\n status\n createdAt\n completedAt\n meta\n }\n }\n serverPublicKey\n}"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/apollo/graphql.ts b/frontend/apollo/graphql.ts index 9a339ee9e..916f4e385 100644 --- a/frontend/apollo/graphql.ts +++ b/frontend/apollo/graphql.ts @@ -3942,7 +3942,7 @@ export type GetAppSyncStatusQueryVariables = Exact<{ }>; -export type GetAppSyncStatusQuery = { __typename?: 'Query', sseEnabled?: boolean | null, serverPublicKey?: string | null, syncs?: Array<{ __typename?: 'EnvironmentSyncType', id: string, path: string, options: any, isActive: boolean, lastSync?: any | null, status: ApiEnvironmentSyncStatusChoices, createdAt?: any | null, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, app: { __typename?: 'AppMembershipType', id: string, name: string } }, serviceInfo?: { __typename?: 'ServiceType', id?: string | null, name?: string | null, provider?: { __typename?: 'ProviderType', id: string } | null } | null, authentication?: { __typename?: 'ProviderCredentialsType', id: string, name: string, credentials: any } | null, history: Array<{ __typename?: 'EnvironmentSyncEventType', id: string, status: ApiEnvironmentSyncEventStatusChoices, createdAt?: any | null, completedAt?: any | null, meta?: any | null }> } | null> | null }; +export type GetAppSyncStatusQuery = { __typename?: 'Query', sseEnabled?: boolean | null, serverPublicKey?: string | null, syncs?: Array<{ __typename?: 'EnvironmentSyncType', id: string, path: string, options: any, isActive: boolean, lastSync?: any | null, status: ApiEnvironmentSyncStatusChoices, createdAt?: any | null, environment: { __typename?: 'EnvironmentType', id: string, name: string, envType: ApiEnvironmentEnvTypeChoices, index: number, app: { __typename?: 'AppMembershipType', id: string, name: string } }, serviceInfo?: { __typename?: 'ServiceType', id?: string | null, name?: string | null, provider?: { __typename?: 'ProviderType', id: string } | null } | null, authentication?: { __typename?: 'ProviderCredentialsType', id: string, name: string, credentials: any } | null, history: Array<{ __typename?: 'EnvironmentSyncEventType', id: string, status: ApiEnvironmentSyncEventStatusChoices, createdAt?: any | null, completedAt?: any | null, meta?: any | null }> } | null> | null }; export type GetProviderListQueryVariables = Exact<{ [key: string]: never; }>; @@ -4190,7 +4190,7 @@ export const ValidateAwsAssumeRoleCredentialsDocument = {"kind":"Document","defi export const GetAzureKeyVaultSecretsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAzureKeyVaultSecrets"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"azureKvSecrets"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}},{"kind":"Argument","name":{"kind":"Name","value":"vaultUri"},"value":{"kind":"Variable","name":{"kind":"Name","value":"vaultUri"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"updatedOn"}},{"kind":"Field","name":{"kind":"Name","value":"contentType"}}]}}]}}]} as unknown as DocumentNode; export const GetCfPagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCfPages"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloudflarePagesProjects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"deploymentId"}},{"kind":"Field","name":{"kind":"Name","value":"environments"}}]}}]}}]} as unknown as DocumentNode; export const GetCfWorkersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetCfWorkers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloudflareWorkers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"credentialId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"credentialId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"scriptId"}}]}}]}}]} as unknown as DocumentNode; -export const GetAppSyncStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppSyncStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; +export const GetAppSyncStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAppSyncStatus"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"appId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sseEnabled"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}]},{"kind":"Field","name":{"kind":"Name","value":"syncs"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"appId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"appId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"environment"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"envType"}},{"kind":"Field","name":{"kind":"Name","value":"index"}},{"kind":"Field","name":{"kind":"Name","value":"app"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"serviceInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"lastSync"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"authentication"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"history"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"completedAt"}},{"kind":"Field","name":{"kind":"Name","value":"meta"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetProviderListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProviderList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"providers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"authScheme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; export const GetSavedCredentialsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSavedCredentials"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedCredentials"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orgId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"orgId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"credentials"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"provider"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expectedCredentials"}},{"kind":"Field","name":{"kind":"Name","value":"optionalCredentials"}}]}},{"kind":"Field","name":{"kind":"Name","value":"syncCount"}}]}}]}}]} as unknown as DocumentNode; export const GetServerKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetServerKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverPublicKey"}}]}}]} as unknown as DocumentNode; diff --git a/frontend/components/syncing/EnvSyncStatus.tsx b/frontend/components/syncing/EnvSyncStatus.tsx index 2a67fdfa0..c43d92bea 100644 --- a/frontend/components/syncing/EnvSyncStatus.tsx +++ b/frontend/components/syncing/EnvSyncStatus.tsx @@ -67,9 +67,11 @@ export const EnvSyncStatus = (props: {
- {syncs.map((sync: EnvironmentSyncType) => ( - - ))} + {[...syncs] + .sort((a, b) => a.environment.index - b.environment.index) + .map((sync: EnvironmentSyncType) => ( + + ))}
diff --git a/frontend/graphql/queries/syncing/getAppSyncStatus.gql b/frontend/graphql/queries/syncing/getAppSyncStatus.gql index 445c43e84..b63f4ac37 100644 --- a/frontend/graphql/queries/syncing/getAppSyncStatus.gql +++ b/frontend/graphql/queries/syncing/getAppSyncStatus.gql @@ -6,6 +6,7 @@ query GetAppSyncStatus($appId: ID!) { id name envType + index app { id name From aed542b6397ea7bc0e8d6bbcbf61aeb4168db119 Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 25 Mar 2026 18:06:47 +0530 Subject: [PATCH 09/12] fix: type Signed-off-by: rohan --- .../environments/secrets/SecretPropertyDiffs.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/components/environments/secrets/SecretPropertyDiffs.tsx b/frontend/components/environments/secrets/SecretPropertyDiffs.tsx index 46ec7a87d..272de6003 100644 --- a/frontend/components/environments/secrets/SecretPropertyDiffs.tsx +++ b/frontend/components/environments/secrets/SecretPropertyDiffs.tsx @@ -1,4 +1,4 @@ -import { ApiSecretTypeChoices, SecretEventType, SecretTagType, SecretType } from '@/apollo/graphql' +import { ApiSecretEventTypeChoices, ApiSecretTypeChoices, SecretEventType, SecretTagType, SecretType } from '@/apollo/graphql' import { areTagsAreSame } from '@/utils/tags' import { FaRedoAlt, FaUndoAlt } from 'react-icons/fa' import { Button } from '../../common/Button' @@ -33,14 +33,14 @@ export const SecretPropertyDiffs = ({ return removedTags } - const isSealed = historyItem!.type === ApiSecretTypeChoices.Sealed - const wasSealed = previousItem.type === ApiSecretTypeChoices.Sealed + const isSealed = historyItem!.type === ApiSecretEventTypeChoices.Sealed + const wasSealed = previousItem.type === ApiSecretEventTypeChoices.Sealed const typeLabel = (type: string) => { switch (type) { - case ApiSecretTypeChoices.Sealed: + case ApiSecretEventTypeChoices.Sealed: return 'Sealed' - case ApiSecretTypeChoices.Config: + case ApiSecretEventTypeChoices.Config: return 'Config' default: return 'Secret' From edc9fa1cedce099badc204bab0a1cba5fbe57b5d Mon Sep 17 00:00:00 2001 From: rohan Date: Sun, 21 Jun 2026 21:42:54 +0530 Subject: [PATCH 10/12] fix(syncing): recursive nested reference resolution and once-per-bulk sync triggers --- ...atus.py => 0131_add_queued_sync_status.py} | 4 +- backend/api/models.py | 14 +- backend/api/tasks/syncing.py | 85 ++- backend/api/utils/secrets.py | 160 +++-- backend/api/views/secrets.py | 565 ++++++++++-------- .../backend/graphene/mutations/environment.py | 250 ++++---- backend/tests/tasks/test_syncing.py | 207 ++++--- backend/tests/utils/test_secret.py | 445 +++++++++----- frontend/apollo/schema.graphql | 12 - .../components/syncing/SyncManagement.tsx | 6 +- frontend/tests/utils/secretReferences.test.ts | 43 ++ frontend/utils/secretReferences.ts | 13 +- 12 files changed, 1065 insertions(+), 739 deletions(-) rename backend/api/migrations/{0120_add_queued_sync_status.py => 0131_add_queued_sync_status.py} (89%) diff --git a/backend/api/migrations/0120_add_queued_sync_status.py b/backend/api/migrations/0131_add_queued_sync_status.py similarity index 89% rename from backend/api/migrations/0120_add_queued_sync_status.py rename to backend/api/migrations/0131_add_queued_sync_status.py index d82ece1ad..d62a42ae7 100644 --- a/backend/api/migrations/0120_add_queued_sync_status.py +++ b/backend/api/migrations/0131_add_queued_sync_status.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.29 on 2026-03-25 11:53 +# Generated by Django 4.2.30 on 2026-06-21 09:52 from django.db import migrations, models @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('api', '0119_secretevent_type_field'), + ('api', '0130_rotating_secrets'), ] operations = [ diff --git a/backend/api/models.py b/backend/api/models.py index bd1a173ae..03c3b42c6 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -790,20 +790,22 @@ class Secret(models.Model): updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(blank=True, null=True) - def save(self, *args, **kwargs): + def save(self, *args, trigger_sync=True, **kwargs): # Call the "real" save() method to save the Secret super().save(*args, **kwargs) - # Update the 'updated_at' timestamp of the associated Environment - if self.environment: + # Notify the environment (bumps updated_at and triggers syncs). Bulk + # callers pass trigger_sync=False and trigger once after the loop so the + # per-env sync jobs and org-wide reference scan run a single time. + if self.environment and trigger_sync: self.environment.updated_at = timezone.now() self.environment.save() - def delete(self, *args, **kwargs): + def delete(self, *args, trigger_sync=True, **kwargs): env = self.environment super().delete(*args, **kwargs) - # Update the 'updated_at' timestamp of the associated Environment - if env: + # See save(): bulk callers defer the trigger and fire it once. + if env and trigger_sync: env.updated_at = timezone.now() env.save() diff --git a/backend/api/tasks/syncing.py b/backend/api/tasks/syncing.py index 27a48e427..5f6604062 100644 --- a/backend/api/tasks/syncing.py +++ b/backend/api/tasks/syncing.py @@ -505,24 +505,27 @@ def perform_azure_kv_sync(environment_sync): def trigger_syncs_for_referencing_envs(changed_env): """ Finds environments with active syncs whose secrets reference the changed - environment, and triggers those syncs to keep referenced values up to date. - - Called synchronously from Environment.save() so that sync status is set to - IN_PROGRESS immediately (the actual sync work is dispatched async by - trigger_sync_tasks). This ensures the UI reflects the pending sync right away. - - This handles the case where env B has a secret like ${staging.DB_HOST} - referencing env "staging" — when a secret in "staging" changes, env B's - syncs need to be triggered too. + environment — directly or transitively through a chain of references — and + triggers those syncs to keep the resolved values up to date. + + Reference resolution is recursive (see decrypt_secret_value), so a sync's + resolved output can depend on the changed environment through intermediate + environments. For example env B has ${A.SHARED}, and A's SHARED is + ${C.BASE}: a change in C must re-trigger B's syncs, not just A's. To handle + this we build the org's reference graph and follow it from each candidate + environment until we reach the changed environment (cycle-safe). + + Called synchronously from Environment.save() so the pending sync status is + reflected in the UI right away; the actual sync work is dispatched async. """ - from api.utils.secrets import env_has_references_to + from api.utils.secrets import get_referenced_environment_ids EnvironmentSync = apps.get_model("api", "EnvironmentSync") + Environment = apps.get_model("api", "Environment") + App = apps.get_model("api", "App") org = changed_env.app.organisation - changed_env_name = changed_env.name - changed_app_name = changed_env.app.name - changed_app_id = changed_env.app_id + changed_env_id = str(changed_env.id) # Find all active syncs in the org, excluding the changed environment candidate_syncs = EnvironmentSync.objects.filter( @@ -536,22 +539,62 @@ def trigger_syncs_for_referencing_envs(changed_env): # Group syncs by environment to avoid redundant reference checks env_syncs_map = {} for sync in candidate_syncs: - env_id = sync.environment_id - if env_id not in env_syncs_map: - env_syncs_map[env_id] = [] - env_syncs_map[env_id].append(sync) + env_syncs_map.setdefault(str(sync.environment_id), []).append(sync) if not env_syncs_map: return + # Pre-build org-wide name -> id resolution maps so references (which use + # names) can be resolved to environment IDs without per-reference queries. + apps_by_name = {} + ambiguous_apps = set() + for app in App.objects.filter(organisation=org, deleted_at=None): + key = app.name.lower() + if key in apps_by_name: + ambiguous_apps.add(key) + apps_by_name[key] = app.id + + envs_by_app_name = {} + for env in Environment.objects.filter(app__organisation=org, deleted_at=None): + envs_by_app_name[(str(env.app_id), env.name.lower())] = str(env.id) + + name_ctx = { + "apps_by_name": apps_by_name, + "ambiguous_apps": ambiguous_apps, + "envs_by_app_name": envs_by_app_name, + } + + # Memoize each environment's direct references so every environment is + # decrypted at most once across all candidate traversals. + ref_cache = {} + + def direct_refs(env_id): + if env_id not in ref_cache: + ref_cache[env_id] = get_referenced_environment_ids(env_id, name_ctx) + return ref_cache[env_id] + + def references_changed_env(start_env_id): + # Depth-first walk of the reference graph from start_env_id, following + # references until the changed environment is reached. Cycle-safe. + visited = set() + stack = [start_env_id] + while stack: + current = stack.pop() + if current in visited: + continue + visited.add(current) + refs = direct_refs(current) + if changed_env_id in refs: + return True + stack.extend(refs - visited) + return False + for env_id, syncs in env_syncs_map.items(): try: - if env_has_references_to( - env_id, changed_env_name, changed_app_name, changed_app_id - ): + if references_changed_env(env_id): logger.info( f"Environment {env_id} references changed environment " - f"{changed_env.id}, triggering syncs" + f"{changed_env.id} (directly or transitively), triggering syncs" ) for sync in syncs: trigger_sync_tasks(sync) diff --git a/backend/api/utils/secrets.py b/backend/api/utils/secrets.py index 76035e599..8c9ca46fb 100644 --- a/backend/api/utils/secrets.py +++ b/backend/api/utils/secrets.py @@ -20,9 +20,18 @@ # Note: The `(?!\{)` negative lookahead intentionally excludes double-brace syntax # (for example, `${{...}}`) to avoid conflicting with Railway and similar third-party # service variable references that use that format. -CROSS_APP_ENV_PATTERN = re.compile(r"\$\{(?!\{)(.+?)::(.+?)\.(.+?)\}") -CROSS_ENV_PATTERN = re.compile(r"\$\{(?!\{)(?![^{]*::)([^.]+?)\.(.+?)\}") -LOCAL_REF_PATTERN = re.compile(r"\$\{(?!\{)([^.]+?)\}") +# The capture groups exclude `{` and `}` so a single reference can never span +# across an adjacent ${...} reference. Without this, a dot-less local ref placed +# before a dotted ref (e.g. "${LOCAL}-${env.KEY}") makes the dotted pattern's +# leading group greedily consume up to the later dot, producing a bogus match. +CROSS_APP_ENV_PATTERN = re.compile(r"\$\{(?!\{)([^{}]+?)::([^{}]+?)\.([^{}]+?)\}") +CROSS_ENV_PATTERN = re.compile(r"\$\{(?!\{)(?![^{}]*::)([^{}.]+?)\.([^{}]+?)\}") +LOCAL_REF_PATTERN = re.compile(r"\$\{(?!\{)([^{}.]+?)\}") + +# Upper bound on how deep nested secret references are resolved. Cycles are +# already broken by the per-branch `_visited` set in decrypt_secret_value; this +# is an additional guard against pathologically deep (but acyclic) chains. +MAX_REFERENCE_DEPTH = 25 class SecretReferenceException(Exception): @@ -296,16 +305,31 @@ def check_environment_access(account, environment, require_resolved_references): return True -def resolve_secret_value(environment, path, key_name, crypto_context=None): +def resolve_secret_value( + environment, + path, + key_name, + crypto_context=None, + require_resolved_references=False, + account=None, + context_cache=None, + _visited=None, +): """ Resolves a secret value from a given environment, path, and key name. + + The referenced secret is decrypted via decrypt_secret_value so that any + references nested inside it (local, cross-env or cross-app) are themselves + resolved recursively. The _visited set is threaded through to break + reference cycles. """ Secret = apps.get_model("api", "Secret") if crypto_context: salt, pubkey, privkey = crypto_context else: - salt, pubkey, privkey = get_environment_crypto_context(environment) + crypto_context = get_environment_crypto_context(environment) + salt, pubkey, privkey = crypto_context key_digest = blake2b_digest(key_name, salt) @@ -315,7 +339,15 @@ def resolve_secret_value(environment, path, key_name, crypto_context=None): key_digest=key_digest, deleted_at=None, ) - return decrypt_asymmetric(secret.value, privkey, pubkey) + + return decrypt_secret_value( + secret, + require_resolved_references=require_resolved_references, + account=account, + crypto_context=crypto_context, + context_cache=context_cache, + _visited=_visited, + ) def decrypt_secret_value( @@ -324,16 +356,23 @@ def decrypt_secret_value( account=None, crypto_context=None, context_cache=None, + _visited=None, ): """ Decrypts the given secret's value and resolves all references. + References are resolved recursively: a referenced value that itself contains + references is fully resolved before being substituted in. Reference cycles + (e.g. A -> B -> A) are detected via the per-branch _visited set and chains + deeper than MAX_REFERENCE_DEPTH are rejected, so resolution always terminates. + Args: secret (Secret): The secret instance to decrypt. require_resolved_references (bool): If True, raise an exception if any reference cannot be resolved. account: (OrganisationMember | ServiceAccount): The account attempting to decrypt the secret value. crypto_context (tuple): Optional pre-computed (salt, pubkey, privkey) for the environment. context_cache (dict): Optional dictionary to cache crypto contexts for referenced environments. + _visited (set): Internal — identities of secrets already on the current resolution branch, used for cycle detection. Returns: value (str): Decrypted secret value, with all local and cross env/app references replaced inline. @@ -343,6 +382,22 @@ def decrypt_secret_value( App = apps.get_model("api", "App") ServerEnvironmentKey = apps.get_model("api", "ServerEnvironmentKey") + # Cycle / depth guard. Identify this secret by (env, path, key_digest) and + # refuse to resolve it again if it's already on the current branch. + if _visited is None: + _visited = set() + + secret_identity = ( + str(getattr(secret, "environment_id", None) or secret.environment.id), + secret.path, + secret.key_digest, + ) + if secret_identity in _visited or len(_visited) >= MAX_REFERENCE_DEPTH: + raise SecretReferenceException( + "Circular or too-deeply-nested secret reference detected." + ) + _visited = _visited | {secret_identity} + # Pre-compute current env context if crypto_context: current_env_crypto_context = crypto_context @@ -389,6 +444,10 @@ def decrypt_secret_value( path, key_name, crypto_context=ref_crypto_context, + require_resolved_references=require_resolved_references, + account=account, + context_cache=context_cache, + _visited=_visited, ) value = value.replace( @@ -442,6 +501,10 @@ def decrypt_secret_value( path, key_name, crypto_context=ref_crypto_context, + require_resolved_references=require_resolved_references, + account=account, + context_cache=context_cache, + _visited=_visited, ) value = value.replace(f"${{{ref_env}.{ref_key}}}", referenced_secret_value) @@ -472,6 +535,10 @@ def decrypt_secret_value( path, key_name, crypto_context=current_env_crypto_context, + require_resolved_references=require_resolved_references, + account=account, + context_cache=context_cache, + _visited=_visited, ) value = value.replace( @@ -489,22 +556,24 @@ def decrypt_secret_value( return value -def env_has_references_to(source_env_id, target_env_name, target_app_name, target_app_id): +def get_referenced_environment_ids(source_env_id, name_ctx): """ - Check if any secrets in the source environment contain references - to the target environment. + Returns the set of environment IDs that the secrets in source_env directly + reference via cross-env (${ENV.KEY}) or cross-app (${APP::ENV.KEY}) references. - Used to determine which syncs need to be triggered when a referenced - environment's secrets change. + Reference names are resolved to environment IDs using the pre-built name_ctx + maps (so the caller can build a reference graph and follow chains transitively + when deciding which syncs to trigger). Returns IDs as strings. Args: - source_env_id: ID of the environment to check secrets in. - target_env_name: Name of the potentially-referenced environment. - target_app_name: Name of the app containing the target environment. - target_app_id: ID of the app containing the target environment. + source_env_id: ID of the environment whose secrets to scan. + name_ctx (dict): Pre-built org-wide lookups: + - "apps_by_name": {lower app name: app_id} (unambiguous names only) + - "ambiguous_apps": set of lower app names that occur more than once + - "envs_by_app_name": {(str(app_id), lower env name): str(env_id)} Returns: - bool: True if any secret in source_env references the target environment. + set[str]: Environment IDs referenced by source_env's secrets. """ Secret = apps.get_model("api", "Secret") ServerEnvironmentKey = apps.get_model("api", "ServerEnvironmentKey") @@ -513,14 +582,12 @@ def env_has_references_to(source_env_id, target_env_name, target_app_name, targe try: source_env = Environment.objects.select_related("app").get(id=source_env_id) except Environment.DoesNotExist: - return False + return set() try: - server_env_key = ServerEnvironmentKey.objects.get( - environment_id=source_env_id - ) + server_env_key = ServerEnvironmentKey.objects.get(environment_id=source_env_id) except ServerEnvironmentKey.DoesNotExist: - return False + return set() pk, sk = get_server_keypair() @@ -528,35 +595,38 @@ def env_has_references_to(source_env_id, target_env_name, target_app_name, targe env_seed = decrypt_asymmetric(server_env_key.wrapped_seed, sk.hex(), pk.hex()) env_pubkey, env_privkey = env_keypair(env_seed) except Exception: - return False + return set() - secrets = Secret.objects.filter( - environment_id=source_env_id, - deleted_at=None, - ) + apps_by_name = name_ctx["apps_by_name"] + ambiguous_apps = name_ctx["ambiguous_apps"] + envs_by_app_name = name_ctx["envs_by_app_name"] + source_app_id = str(source_env.app_id) - same_app = str(source_env.app_id) == str(target_app_id) - target_env_lower = target_env_name.lower() - target_app_lower = target_app_name.lower() + referenced = set() - for secret in secrets: + for secret in Secret.objects.filter(environment_id=source_env_id, deleted_at=None): try: value = decrypt_asymmetric(secret.value, env_privkey, env_pubkey) - - # Check cross-app references: ${APP::ENV.KEY} - for ref_app, ref_env, _ in CROSS_APP_ENV_PATTERN.findall(value): - if ( - ref_app.lower() == target_app_lower - and ref_env.lower() == target_env_lower - ): - return True - - # Check cross-env references: ${ENV.KEY} (only if same app) - if same_app: - for ref_env, _ in CROSS_ENV_PATTERN.findall(value): - if ref_env.lower() == target_env_lower: - return True except Exception: continue - return False + # Cross-app references: ${APP::ENV.KEY} + for ref_app, ref_env, _ in CROSS_APP_ENV_PATTERN.findall(value): + app_key = ref_app.lower() + if app_key in ambiguous_apps: + # Can't unambiguously resolve which app is meant — skip. + continue + app_id = apps_by_name.get(app_key) + if app_id is None: + continue + env_id = envs_by_app_name.get((str(app_id), ref_env.lower())) + if env_id: + referenced.add(env_id) + + # Cross-env references: ${ENV.KEY} (same app) + for ref_env, _ in CROSS_ENV_PATTERN.findall(value): + env_id = envs_by_app_name.get((source_app_id, ref_env.lower())) + if env_id: + referenced.add(env_id) + + return referenced diff --git a/backend/api/views/secrets.py b/backend/api/views/secrets.py index 530ad4be4..606bcf584 100644 --- a/backend/api/views/secrets.py +++ b/backend/api/views/secrets.py @@ -369,56 +369,62 @@ def post(self, request, *args, **kwargs): created_secrets = [] - for secret in request_body["secrets"]: - - # Check that all encrypted fields are valid - encrypted_fields = [secret["key"], secret["value"], secret["comment"]] - if "override" in secret: - encrypted_fields.append(secret["override"]["value"]) - - for encrypted_field in encrypted_fields: - if not validate_encrypted_string(encrypted_field): - return JsonResponse( - {"error": "Invalid ciphertext format"}, status=400 - ) + # Defer per-secret sync triggering (trigger_sync=False); trigger once below. + try: + for secret in request_body["secrets"]: - tags, err = _resolve_secret_tags(secret.get("tags") or [], env.app.organisation) - if err is not None: - return err + # Check that all encrypted fields are valid + encrypted_fields = [secret["key"], secret["value"], secret["comment"]] + if "override" in secret: + encrypted_fields.append(secret["override"]["value"]) - try: - path = normalize_path_string(secret["path"]) - except: - path = "/" + for encrypted_field in encrypted_fields: + if not validate_encrypted_string(encrypted_field): + return JsonResponse( + {"error": "Invalid ciphertext format"}, status=400 + ) - folder = None + tags, err = _resolve_secret_tags(secret.get("tags") or [], env.app.organisation) + if err is not None: + return err - if path != "/": - folder = create_environment_folder_structure(path, env.id) + try: + path = normalize_path_string(secret["path"]) + except: + path = "/" - secret_data = { - "environment": env, - "path": path, - "folder": folder, - "key": secret["key"], - "key_digest": secret["keyDigest"], - "value": secret["value"], - "version": 1, - "comment": secret["comment"], - "type": secret.get("type", "secret"), - } + folder = None - secret_obj = Secret.objects.create(**secret_data) - secret_obj.tags.set(tags) - created_secrets.append(secret_obj) + if path != "/": + folder = create_environment_folder_structure(path, env.id) - # If the request is authenticated as a user and an override is supplied - if request.auth["org_member"] and "override" in secret: - PersonalSecret.objects.create( - secret=secret_obj, - user=request.auth["org_member"], - value=secret["override"]["value"], - ) + secret_data = { + "environment": env, + "path": path, + "folder": folder, + "key": secret["key"], + "key_digest": secret["keyDigest"], + "value": secret["value"], + "version": 1, + "comment": secret["comment"], + "type": secret.get("type", "secret"), + } + + secret_obj = Secret(**secret_data) + secret_obj.save(force_insert=True, trigger_sync=False) + secret_obj.tags.set(tags) + created_secrets.append(secret_obj) + + # If the request is authenticated as a user and an override is supplied + if request.auth["org_member"] and "override" in secret: + PersonalSecret.objects.create( + secret=secret_obj, + user=request.auth["org_member"], + value=secret["override"]["value"], + ) + finally: + if created_secrets: + env.save() log_secret_events_bulk( created_secrets, @@ -449,106 +455,111 @@ def put(self, request, *args, **kwargs): updated_secrets = [] - for secret in request_body["secrets"]: - - secret_obj = Secret.objects.get(id=secret["id"]) - - if secret_obj.rotating_secret_id is not None: - return JsonResponse( - { - "error": ( - "Rotating secrets are managed by the Phase rotation " - "engine and cannot be updated via this endpoint." - ) - }, - status=400, - ) + # Defer per-secret sync triggering (trigger_sync=False); trigger once below. + try: + for secret in request_body["secrets"]: - tags, err = _resolve_secret_tags(secret.get("tags") or [], env.app.organisation) - if err is not None: - return err + secret_obj = Secret.objects.get(id=secret["id"]) - if "key" not in secret: - secret["key"] = secret_obj.key - try: - secret["keyDigest"] = secret_obj.key_digest - except: + if secret_obj.rotating_secret_id is not None: return JsonResponse( - {"error": "Key supplied without digest"}, status=400 + { + "error": ( + "Rotating secrets are managed by the Phase rotation " + "engine and cannot be updated via this endpoint." + ) + }, + status=400, ) - if "value" not in secret: - secret["value"] = secret_obj.value + tags, err = _resolve_secret_tags(secret.get("tags") or [], env.app.organisation) + if err is not None: + return err - if "comment" not in secret: - secret["comment"] = secret_obj.comment + if "key" not in secret: + secret["key"] = secret_obj.key + try: + secret["keyDigest"] = secret_obj.key_digest + except: + return JsonResponse( + {"error": "Key supplied without digest"}, status=400 + ) - # Check that all encrypted fields are valid - encrypted_fields = [secret["key"], secret["value"], secret["comment"]] - if "override" in secret: - encrypted_fields.append(secret["override"]["value"]) + if "value" not in secret: + secret["value"] = secret_obj.value + + if "comment" not in secret: + secret["comment"] = secret_obj.comment + + # Check that all encrypted fields are valid + encrypted_fields = [secret["key"], secret["value"], secret["comment"]] + if "override" in secret: + encrypted_fields.append(secret["override"]["value"]) + + for encrypted_field in encrypted_fields: + if not validate_encrypted_string(encrypted_field): + return JsonResponse( + {"error": "Invalid ciphertext format"}, status=400 + ) - for encrypted_field in encrypted_fields: - if not validate_encrypted_string(encrypted_field): + # Enforce seal permanence + if secret_obj.type == "sealed" and secret.get("type") is not None and secret.get("type") != "sealed": return JsonResponse( - {"error": "Invalid ciphertext format"}, status=400 + {"error": "Sealed secrets cannot be unsealed. Delete and recreate the secret instead."}, + status=400, ) - # Enforce seal permanence - if secret_obj.type == "sealed" and secret.get("type") is not None and secret.get("type") != "sealed": - return JsonResponse( - {"error": "Sealed secrets cannot be unsealed. Delete and recreate the secret instead."}, - status=400, - ) + secret_data = { + "environment": env, + "key": secret["key"], + "key_digest": secret["keyDigest"], + "value": secret["value"], + "version": secret_obj.version + 1, + "comment": secret["comment"], + } - secret_data = { - "environment": env, - "key": secret["key"], - "key_digest": secret["keyDigest"], - "value": secret["value"], - "version": secret_obj.version + 1, - "comment": secret["comment"], - } + # For sealed secrets, preserve existing encrypted value + if secret_obj.type == "sealed": + secret_data["value"] = secret_obj.value - # For sealed secrets, preserve existing encrypted value - if secret_obj.type == "sealed": - secret_data["value"] = secret_obj.value + # Set type if provided + if "type" in secret: + secret_data["type"] = secret["type"] - # Set type if provided - if "type" in secret: - secret_data["type"] = secret["type"] + try: + folder = None + path = normalize_path_string(secret["path"]) - try: - folder = None - path = normalize_path_string(secret["path"]) + if path != "/": + folder = create_environment_folder_structure(path, env.id) - if path != "/": - folder = create_environment_folder_structure(path, env.id) + secret_data["path"] = path + secret_data["folder"] = folder + except: + pass - secret_data["path"] = path - secret_data["folder"] = folder - except: - pass + for key, value in secret_data.items(): + setattr(secret_obj, key, value) - for key, value in secret_data.items(): - setattr(secret_obj, key, value) - - secret_obj.updated_at = timezone.now() - secret_obj.tags.set(tags) - secret_obj.save() - updated_secrets.append(secret_obj) - - # If the request is authenticated as a user and an override is supplied - if request.auth["org_member"] and "override" in secret: - PersonalSecret.objects.update_or_create( - secret=secret_obj, - user=request.auth["org_member"], - defaults={ - "value": secret["override"]["value"], - "is_active": secret["override"]["isActive"], - "updated_at": timezone.now(), - }, - ) + secret_obj.updated_at = timezone.now() + secret_obj.tags.set(tags) + secret_obj.save(trigger_sync=False) + updated_secrets.append(secret_obj) + + # If the request is authenticated as a user and an override is supplied + if request.auth["org_member"] and "override" in secret: + PersonalSecret.objects.update_or_create( + secret=secret_obj, + user=request.auth["org_member"], + defaults={ + "value": secret["override"]["value"], + "is_active": secret["override"]["isActive"], + "updated_at": timezone.now(), + }, + ) + finally: + if updated_secrets: + env.save() log_secret_events_bulk( updated_secrets, @@ -588,14 +599,21 @@ def delete(self, request, *args, **kwargs): status=400, ) - env = secrets_to_delete[0].environment - deleted_secrets = [] - for secret in secrets_to_delete: - secret.updated_at = timezone.now() - secret.deleted_at = timezone.now() - secret.save() - deleted_secrets.append(secret) + affected_envs = {} + + # Defer per-secret sync triggering; trigger once per affected environment + # afterwards (a delete batch may span environments). + try: + for secret in secrets_to_delete: + affected_envs[secret.environment_id] = secret.environment + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save(trigger_sync=False) + deleted_secrets.append(secret) + finally: + for env in affected_envs.values(): + env.save() log_secret_events_bulk( deleted_secrets, @@ -933,52 +951,58 @@ def post(self, request, *args, **kwargs): created_secrets = [] - for secret in secrets: - - try: - path = normalize_path_string(secret["path"]) - except: - path = "/" - - folder = None - - if path != "/": - folder = create_environment_folder_structure(path, env.id) + # Defer per-secret sync triggering (trigger_sync=False); trigger once below. + try: + for secret in secrets: - secret_data = { - "environment": env, - "path": path, - "folder": folder, - "key": secret["key"], - "key_digest": secret["keyDigest"], - "value": secret["value"], - "version": 1, - "comment": secret["comment"], - "type": secret.get("type", "secret"), - } + try: + path = normalize_path_string(secret["path"]) + except: + path = "/" - secret_obj = Secret.objects.create(**secret_data) + folder = None - # Optionally set tags (auto-create unknown names so the caller - # doesn't silently lose them — see _resolve_secret_tags). - if "tags" in secret: - tags, err = _resolve_secret_tags( - secret["tags"], - request.auth["environment"].app.organisation, - ) - if err is not None: - return err - secret_obj.tags.set(tags) + if path != "/": + folder = create_environment_folder_structure(path, env.id) - # If the request is authenticated as a user and an override is supplied - if request.auth["org_member"] and "override" in secret: - PersonalSecret.objects.create( - secret=secret_obj, - user=request.auth["org_member"], - value=secret["override"]["value"], - ) + secret_data = { + "environment": env, + "path": path, + "folder": folder, + "key": secret["key"], + "key_digest": secret["keyDigest"], + "value": secret["value"], + "version": 1, + "comment": secret["comment"], + "type": secret.get("type", "secret"), + } + + secret_obj = Secret(**secret_data) + secret_obj.save(force_insert=True, trigger_sync=False) + + # Optionally set tags (auto-create unknown names so the caller + # doesn't silently lose them — see _resolve_secret_tags). + if "tags" in secret: + tags, err = _resolve_secret_tags( + secret["tags"], + request.auth["environment"].app.organisation, + ) + if err is not None: + return err + secret_obj.tags.set(tags) + + # If the request is authenticated as a user and an override is supplied + if request.auth["org_member"] and "override" in secret: + PersonalSecret.objects.create( + secret=secret_obj, + user=request.auth["org_member"], + value=secret["override"]["value"], + ) - created_secrets.append(secret_obj) + created_secrets.append(secret_obj) + finally: + if created_secrets: + env.save() log_secret_events_bulk( created_secrets, @@ -1064,108 +1088,113 @@ def put(self, request, *args, **kwargs): updated_secrets = [] - for secret in secrets: - - try: - secret_obj = Secret.objects.get(id=secret["id"], environment=env) - except (Secret.DoesNotExist, ValueError): - return JsonResponse( - {"error": f"Secret not found: {secret['id']}"}, - status=404, - ) - - if secret_obj.rotating_secret_id is not None: - return JsonResponse( - { - "error": ( - "Rotating secrets are managed by the Phase rotation " - "engine and cannot be updated via this endpoint." - ) - }, - status=400, - ) - - if "key" not in secret: - secret["key"] = secret_obj.key - secret["keyDigest"] = secret_obj.key_digest + # Defer per-secret sync triggering (trigger_sync=False); trigger once below. + try: + for secret in secrets: - if "value" in secret: - secret["value"] = encrypt_asymmetric(secret["value"], env_pubkey) - else: - secret["value"] = secret_obj.value + try: + secret_obj = Secret.objects.get(id=secret["id"], environment=env) + except (Secret.DoesNotExist, ValueError): + return JsonResponse( + {"error": f"Secret not found: {secret['id']}"}, + status=404, + ) - if "comment" in secret: - secret["comment"] = encrypt_asymmetric(secret["comment"], env_pubkey) - else: - secret["comment"] = secret_obj.comment + if secret_obj.rotating_secret_id is not None: + return JsonResponse( + { + "error": ( + "Rotating secrets are managed by the Phase rotation " + "engine and cannot be updated via this endpoint." + ) + }, + status=400, + ) - # Enforce seal permanence - if secret_obj.type == "sealed" and secret.get("type") is not None and secret.get("type") != "sealed": - return JsonResponse( - {"error": "Sealed secrets cannot be unsealed. Delete and recreate the secret instead."}, - status=400, - ) + if "key" not in secret: + secret["key"] = secret_obj.key + secret["keyDigest"] = secret_obj.key_digest - secret_data = { - "environment": env, - "key": secret["key"], - "key_digest": secret["keyDigest"], - "value": secret["value"], - "version": secret_obj.version + 1, - "comment": secret["comment"], - } + if "value" in secret: + secret["value"] = encrypt_asymmetric(secret["value"], env_pubkey) + else: + secret["value"] = secret_obj.value - # For sealed secrets, preserve existing encrypted value - if secret_obj.type == "sealed": - secret_data["value"] = secret_obj.value + if "comment" in secret: + secret["comment"] = encrypt_asymmetric(secret["comment"], env_pubkey) + else: + secret["comment"] = secret_obj.comment - # Set type if provided - if "type" in secret: - secret_data["type"] = secret["type"] + # Enforce seal permanence + if secret_obj.type == "sealed" and secret.get("type") is not None and secret.get("type") != "sealed": + return JsonResponse( + {"error": "Sealed secrets cannot be unsealed. Delete and recreate the secret instead."}, + status=400, + ) - try: - folder = None - path = normalize_path_string(secret["path"]) + secret_data = { + "environment": env, + "key": secret["key"], + "key_digest": secret["keyDigest"], + "value": secret["value"], + "version": secret_obj.version + 1, + "comment": secret["comment"], + } - if path != "/": - folder = create_environment_folder_structure(path, env.id) + # For sealed secrets, preserve existing encrypted value + if secret_obj.type == "sealed": + secret_data["value"] = secret_obj.value - secret_data["path"] = path - secret_data["folder"] = folder - except: - pass + # Set type if provided + if "type" in secret: + secret_data["type"] = secret["type"] - for key, value in secret_data.items(): - setattr(secret_obj, key, value) + try: + folder = None + path = normalize_path_string(secret["path"]) - # Optionally update tags (auto-create unknown names so the - # caller doesn't lose them — see _resolve_secret_tags). - if "tags" in secret: - tags, err = _resolve_secret_tags( - secret["tags"], - request.auth["environment"].app.organisation, - ) - if err is not None: - return err - secret_obj.tags.set(tags) + if path != "/": + folder = create_environment_folder_structure(path, env.id) - secret_obj.updated_at = timezone.now() + secret_data["path"] = path + secret_data["folder"] = folder + except: + pass - secret_obj.save() + for key, value in secret_data.items(): + setattr(secret_obj, key, value) - # If the request is authenticated as a user and an override is supplied - if request.auth["org_member"] and "override" in secret: - PersonalSecret.objects.update_or_create( - secret=secret_obj, - user=request.auth["org_member"], - defaults={ - "value": secret["override"]["value"], - "is_active": secret["override"]["isActive"], - "updated_at": timezone.now(), - }, - ) + # Optionally update tags (auto-create unknown names so the + # caller doesn't lose them — see _resolve_secret_tags). + if "tags" in secret: + tags, err = _resolve_secret_tags( + secret["tags"], + request.auth["environment"].app.organisation, + ) + if err is not None: + return err + secret_obj.tags.set(tags) + + secret_obj.updated_at = timezone.now() + + secret_obj.save(trigger_sync=False) + + # If the request is authenticated as a user and an override is supplied + if request.auth["org_member"] and "override" in secret: + PersonalSecret.objects.update_or_create( + secret=secret_obj, + user=request.auth["org_member"], + defaults={ + "value": secret["override"]["value"], + "is_active": secret["override"]["isActive"], + "updated_at": timezone.now(), + }, + ) - updated_secrets.append(secret_obj) + updated_secrets.append(secret_obj) + finally: + if updated_secrets: + env.save() log_secret_events_bulk( updated_secrets, @@ -1248,11 +1277,17 @@ def delete(self, request, *args, **kwargs): ) deleted_secrets = [] - for secret in secrets_to_delete: - secret.updated_at = timezone.now() - secret.deleted_at = timezone.now() - secret.save() - deleted_secrets.append(secret) + + # Defer per-secret sync triggering (trigger_sync=False); trigger once below. + try: + for secret in secrets_to_delete: + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save(trigger_sync=False) + deleted_secrets.append(secret) + finally: + if deleted_secrets: + env.save() log_secret_events_bulk( deleted_secrets, diff --git a/backend/backend/graphene/mutations/environment.py b/backend/backend/graphene/mutations/environment.py index da64f4217..f6c3e5b44 100644 --- a/backend/backend/graphene/mutations/environment.py +++ b/backend/backend/graphene/mutations/environment.py @@ -1075,48 +1075,60 @@ class Arguments: @classmethod def mutate(cls, root, info, secrets_data): created_secrets = [] + affected_envs = {} + + # Defer per-secret sync triggering (trigger_sync=False) and trigger once + # per affected environment afterwards, so a bulk write fires each env's + # sync jobs + reference scan a single time instead of once per secret. + try: + for secret_data in secrets_data: + env = Environment.objects.get(id=secret_data.env_id) + org = env.app.organisation + + if not user_has_permission( + info.context.user, "create", "Secrets", org, True, app=env.app + ): + raise GraphQLError( + "You don't have permission to create secrets in this organisation" + ) - for secret_data in secrets_data: - env = Environment.objects.get(id=secret_data.env_id) - org = env.app.organisation - - if not user_has_permission( - info.context.user, "create", "Secrets", org, True, app=env.app - ): - raise GraphQLError( - "You don't have permission to create secrets in this organisation" - ) - - if not user_can_access_environment(info.context.user.userId, env.id): - raise GraphQLError("You don't have access to this environment") + if not user_can_access_environment(info.context.user.userId, env.id): + raise GraphQLError("You don't have access to this environment") - tags = SecretTag.objects.filter(id__in=secret_data.tags) + tags = SecretTag.objects.filter(id__in=secret_data.tags) - path = ( - normalize_path_string(secret_data.path) - if secret_data.path is not None - else "/" - ) + path = ( + normalize_path_string(secret_data.path) + if secret_data.path is not None + else "/" + ) - folder = None - if path != "/": - folder = create_environment_folder_structure(path, secret_data.env_id) - - secret_obj_data = { - "environment_id": env.id, - "path": path, - "folder_id": folder.id if folder is not None else None, - "key": secret_data.key, - "key_digest": secret_data.key_digest, - "value": secret_data.value, - "version": 1, - "comment": secret_data.comment, - "type": secret_data.type or "secret", - } + folder = None + if path != "/": + folder = create_environment_folder_structure( + path, secret_data.env_id + ) - secret = Secret.objects.create(**secret_obj_data) - secret.tags.set(tags) - created_secrets.append(secret) + secret_obj_data = { + "environment_id": env.id, + "path": path, + "folder_id": folder.id if folder is not None else None, + "key": secret_data.key, + "key_digest": secret_data.key_digest, + "value": secret_data.value, + "version": 1, + "comment": secret_data.comment, + "type": secret_data.type or "secret", + } + + secret = Secret(**secret_obj_data) + secret.save(force_insert=True, trigger_sync=False) + secret.tags.set(tags) + created_secrets.append(secret) + affected_envs[env.id] = env + finally: + for env in affected_envs.values(): + env.save() if created_secrets: ip_address, user_agent = get_resolver_request_meta(info.context) @@ -1258,65 +1270,72 @@ class Arguments: @classmethod def mutate(cls, root, info, secrets_data): updated_secrets = [] + affected_envs = {} + + # Defer per-secret sync triggering; trigger once per env afterwards. + try: + for secret_data in secrets_data: + secret = Secret.objects.get(id=secret_data.id) + env = secret.environment + org = env.app.organisation + + if not user_has_permission( + info.context.user, "create", "Secrets", org, True, app=env.app + ): + raise GraphQLError( + "You don't have permission to update secrets in this organisation" + ) - for secret_data in secrets_data: - secret = Secret.objects.get(id=secret_data.id) - env = secret.environment - org = env.app.organisation - - if not user_has_permission( - info.context.user, "create", "Secrets", org, True, app=env.app - ): - raise GraphQLError( - "You don't have permission to update secrets in this organisation" - ) - - if not user_can_access_environment(info.context.user.userId, env.id): - raise GraphQLError("You don't have access to this environment") - - # Enforce seal permanence - if secret.type == "sealed" and secret_data.type is not None and secret_data.type != "sealed": - raise GraphQLError("Sealed secrets cannot be unsealed. Delete and recreate the secret instead.") - - tags = SecretTag.objects.filter(id__in=secret_data.tags) - - path = ( - normalize_path_string(secret_data.path) - if secret_data.path is not None - else "/" - ) - - secret_obj_data = { - "path": path, - "key": secret_data.key, - "key_digest": secret_data.key_digest, - "value": secret_data.value, - "version": secret.version + 1, - "comment": secret_data.comment, - } - - # For sealed secrets, preserve existing encrypted value - if secret.type == "sealed": - secret_obj_data["value"] = secret.value + if not user_can_access_environment(info.context.user.userId, env.id): + raise GraphQLError("You don't have access to this environment") - # Rotating-owned rows: engine owns value/path; key can be renamed - # (we sync key_map below). value/path stay frozen. - if secret.rotating_secret_id is not None: - secret_obj_data["path"] = secret.path - secret_obj_data["value"] = secret.value - _sync_rotating_key_map(secret, secret_obj_data["key"], secret_obj_data["key_digest"]) + # Enforce seal permanence + if secret.type == "sealed" and secret_data.type is not None and secret_data.type != "sealed": + raise GraphQLError("Sealed secrets cannot be unsealed. Delete and recreate the secret instead.") - # Set type if provided (and not already sealed) - if secret_data.type is not None: - secret_obj_data["type"] = secret_data.type + tags = SecretTag.objects.filter(id__in=secret_data.tags) - for key, value in secret_obj_data.items(): - setattr(secret, key, value) + path = ( + normalize_path_string(secret_data.path) + if secret_data.path is not None + else "/" + ) - secret.updated_at = timezone.now() - secret.tags.set(tags) - secret.save() - updated_secrets.append(secret) + secret_obj_data = { + "path": path, + "key": secret_data.key, + "key_digest": secret_data.key_digest, + "value": secret_data.value, + "version": secret.version + 1, + "comment": secret_data.comment, + } + + # For sealed secrets, preserve existing encrypted value + if secret.type == "sealed": + secret_obj_data["value"] = secret.value + + # Rotating-owned rows: engine owns value/path; key can be renamed + # (we sync key_map below). value/path stay frozen. + if secret.rotating_secret_id is not None: + secret_obj_data["path"] = secret.path + secret_obj_data["value"] = secret.value + _sync_rotating_key_map(secret, secret_obj_data["key"], secret_obj_data["key_digest"]) + + # Set type if provided (and not already sealed) + if secret_data.type is not None: + secret_obj_data["type"] = secret_data.type + + for key, value in secret_obj_data.items(): + setattr(secret, key, value) + + secret.updated_at = timezone.now() + secret.tags.set(tags) + secret.save(trigger_sync=False) + updated_secrets.append(secret) + affected_envs[env.id] = env + finally: + for env in affected_envs.values(): + env.save() if updated_secrets: ip_address, user_agent = get_resolver_request_meta(info.context) @@ -1389,31 +1408,38 @@ class Arguments: @classmethod def mutate(cls, root, info, ids): deleted_secrets = [] + affected_envs = {} - for id in ids: - secret = Secret.objects.get(id=id) - env = secret.environment - org = env.app.organisation - - if not user_has_permission( - info.context.user, "delete", "Secrets", org, True, app=env.app - ): - raise GraphQLError( - "You don't have permission to delete secrets in this organisation" - ) + # Defer per-secret sync triggering; trigger once per env afterwards. + try: + for id in ids: + secret = Secret.objects.get(id=id) + env = secret.environment + org = env.app.organisation + + if not user_has_permission( + info.context.user, "delete", "Secrets", org, True, app=env.app + ): + raise GraphQLError( + "You don't have permission to delete secrets in this organisation" + ) - if not user_can_access_environment(info.context.user.userId, env.id): - raise GraphQLError("You don't have access to this environment") + if not user_can_access_environment(info.context.user.userId, env.id): + raise GraphQLError("You don't have access to this environment") - if secret.rotating_secret_id is not None: - raise GraphQLError( - "Rotating secrets must be deleted from the manage dialog." - ) + if secret.rotating_secret_id is not None: + raise GraphQLError( + "Rotating secrets must be deleted from the manage dialog." + ) - secret.updated_at = timezone.now() - secret.deleted_at = timezone.now() - secret.save() - deleted_secrets.append(secret) + secret.updated_at = timezone.now() + secret.deleted_at = timezone.now() + secret.save(trigger_sync=False) + deleted_secrets.append(secret) + affected_envs[env.id] = env + finally: + for env in affected_envs.values(): + env.save() if deleted_secrets: ip_address, user_agent = get_resolver_request_meta(info.context) diff --git a/backend/tests/tasks/test_syncing.py b/backend/tests/tasks/test_syncing.py index 1d21d3079..fd4a93936 100644 --- a/backend/tests/tasks/test_syncing.py +++ b/backend/tests/tasks/test_syncing.py @@ -1,79 +1,104 @@ -import pytest -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock from api.tasks.syncing import trigger_syncs_for_referencing_envs -@patch("api.tasks.syncing.trigger_sync_tasks") -@patch("api.tasks.syncing.apps.get_model") -def test_trigger_syncs_for_referencing_envs_with_references( - mock_get_model, mock_trigger_sync -): - """Test that syncs are triggered for environments whose secrets reference the changed env""" +# --- trigger_syncs_for_referencing_envs tests --- +# +# Reference resolution is stubbed via get_referenced_environment_ids so these +# tests exercise the graph-traversal / triggering logic directly. The stub maps +# an environment id to the set of environment ids it directly references; the +# function under test follows those edges (transitively) to the changed env. + + +def _make_changed_env(): changed_env = MagicMock() changed_env.id = "changed-env-id" changed_env.name = "staging" changed_env.app.name = "my-app" changed_env.app_id = "app-1" changed_env.app.organisation = MagicMock() + return changed_env + - candidate_sync = MagicMock() - candidate_sync.environment_id = "candidate-env-id" - candidate_sync.environment = MagicMock() +def _sync(env_id): + s = MagicMock() + s.environment_id = env_id + s.environment = MagicMock() + return s + +def _mock_models(candidate_syncs): + """get_model side_effect: EnvironmentSync yields the given candidate syncs; + App/Environment lookups are empty (name resolution is stubbed elsewhere).""" MockEnvironmentSync = MagicMock() - MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ - candidate_sync - ] + MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = ( + candidate_syncs + ) + MockApp = MagicMock() + MockApp.objects.filter.return_value = [] + MockEnvironment = MagicMock() + MockEnvironment.objects.filter.return_value = [] def get_model_side_effect(app_label, model_name): if model_name == "EnvironmentSync": return MockEnvironmentSync + if model_name == "App": + return MockApp + if model_name == "Environment": + return MockEnvironment return MagicMock() - mock_get_model.side_effect = get_model_side_effect + return get_model_side_effect - with patch( - "api.utils.secrets.env_has_references_to", return_value=True - ) as mock_has_refs: - trigger_syncs_for_referencing_envs(changed_env) - mock_has_refs.assert_called_once_with( - "candidate-env-id", "staging", "my-app", "app-1" - ) - - mock_trigger_sync.assert_called_once_with(candidate_sync) +def _patch_refs(graph): + return patch( + "api.utils.secrets.get_referenced_environment_ids", + side_effect=lambda env_id, name_ctx: graph.get(env_id, set()), + ) @patch("api.tasks.syncing.trigger_sync_tasks") @patch("api.tasks.syncing.apps.get_model") -def test_trigger_syncs_for_referencing_envs_no_references( - mock_get_model, mock_trigger_sync -): - """Test that syncs are NOT triggered when no references exist""" - changed_env = MagicMock() - changed_env.id = "changed-env-id" - changed_env.name = "staging" - changed_env.app.name = "my-app" - changed_env.app_id = "app-1" - changed_env.app.organisation = MagicMock() +def test_trigger_syncs_direct_reference(mock_get_model, mock_trigger_sync): + """A candidate env that directly references the changed env is triggered.""" + changed_env = _make_changed_env() + candidate_sync = _sync("candidate-env-id") + mock_get_model.side_effect = _mock_models([candidate_sync]) + + graph = {"candidate-env-id": {"changed-env-id"}} + with _patch_refs(graph): + trigger_syncs_for_referencing_envs(changed_env) - candidate_sync = MagicMock() - candidate_sync.environment_id = "candidate-env-id" - candidate_sync.environment = MagicMock() + mock_trigger_sync.assert_called_once_with(candidate_sync) - MockEnvironmentSync = MagicMock() - MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ - candidate_sync - ] - def get_model_side_effect(app_label, model_name): - if model_name == "EnvironmentSync": - return MockEnvironmentSync - return MagicMock() +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_transitive_reference(mock_get_model, mock_trigger_sync): + """B -> A -> changed: changing 'changed' must trigger B's syncs (chained).""" + changed_env = _make_changed_env() + sync_b = _sync("env-B") + mock_get_model.side_effect = _mock_models([sync_b]) + + # env-B references env-A; env-A references the changed env. + graph = {"env-B": {"env-A"}, "env-A": {"changed-env-id"}} + with _patch_refs(graph): + trigger_syncs_for_referencing_envs(changed_env) + + mock_trigger_sync.assert_called_once_with(sync_b) - mock_get_model.side_effect = get_model_side_effect - with patch("api.utils.secrets.env_has_references_to", return_value=False): +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_no_reference(mock_get_model, mock_trigger_sync): + """No path from the candidate to the changed env => no trigger.""" + changed_env = _make_changed_env() + candidate_sync = _sync("candidate-env-id") + mock_get_model.side_effect = _mock_models([candidate_sync]) + + graph = {"candidate-env-id": {"some-other-env"}, "some-other-env": set()} + with _patch_refs(graph): trigger_syncs_for_referencing_envs(changed_env) mock_trigger_sync.assert_not_called() @@ -81,29 +106,25 @@ def get_model_side_effect(app_label, model_name): @patch("api.tasks.syncing.trigger_sync_tasks") @patch("api.tasks.syncing.apps.get_model") -def test_trigger_syncs_for_referencing_envs_no_candidate_syncs( - mock_get_model, mock_trigger_sync -): - """Test early return when no other active syncs exist in the org""" - changed_env = MagicMock() - changed_env.id = "changed-env-id" - changed_env.name = "staging" - changed_env.app.name = "my-app" - changed_env.app_id = "app-1" - changed_env.app.organisation = MagicMock() +def test_trigger_syncs_reference_cycle_is_safe(mock_get_model, mock_trigger_sync): + """A reference cycle that never reaches the changed env terminates safely.""" + changed_env = _make_changed_env() + sync_b = _sync("env-B") + mock_get_model.side_effect = _mock_models([sync_b]) + + graph = {"env-B": {"env-A"}, "env-A": {"env-B"}} # cycle, no path to changed + with _patch_refs(graph): + trigger_syncs_for_referencing_envs(changed_env) - MockEnvironmentSync = MagicMock() - # Empty queryset — no candidate syncs - MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = ( - [] - ) + mock_trigger_sync.assert_not_called() - def get_model_side_effect(app_label, model_name): - if model_name == "EnvironmentSync": - return MockEnvironmentSync - return MagicMock() - mock_get_model.side_effect = get_model_side_effect +@patch("api.tasks.syncing.trigger_sync_tasks") +@patch("api.tasks.syncing.apps.get_model") +def test_trigger_syncs_no_candidate_syncs(mock_get_model, mock_trigger_sync): + """Early return when no other active syncs exist in the org.""" + changed_env = _make_changed_env() + mock_get_model.side_effect = _mock_models([]) trigger_syncs_for_referencing_envs(changed_env) @@ -112,45 +133,19 @@ def get_model_side_effect(app_label, model_name): @patch("api.tasks.syncing.trigger_sync_tasks") @patch("api.tasks.syncing.apps.get_model") -def test_trigger_syncs_multiple_envs_only_matching_triggered( - mock_get_model, mock_trigger_sync -): - """Test that only syncs for environments with references are triggered""" - changed_env = MagicMock() - changed_env.id = "changed-env-id" - changed_env.name = "staging" - changed_env.app.name = "my-app" - changed_env.app_id = "app-1" - changed_env.app.organisation = MagicMock() - - # Two candidate syncs on different environments - sync_with_ref = MagicMock() - sync_with_ref.environment_id = "env-with-ref" - sync_with_ref.environment = MagicMock() - - sync_without_ref = MagicMock() - sync_without_ref.environment_id = "env-without-ref" - sync_without_ref.environment = MagicMock() - - MockEnvironmentSync = MagicMock() - MockEnvironmentSync.objects.filter.return_value.exclude.return_value.select_related.return_value = [ - sync_with_ref, - sync_without_ref, - ] - - def get_model_side_effect(app_label, model_name): - if model_name == "EnvironmentSync": - return MockEnvironmentSync - return MagicMock() - - mock_get_model.side_effect = get_model_side_effect - - def has_refs_side_effect(source_env_id, *args): - return source_env_id == "env-with-ref" - - with patch( - "api.utils.secrets.env_has_references_to", side_effect=has_refs_side_effect - ): +def test_trigger_syncs_only_matching_envs_triggered(mock_get_model, mock_trigger_sync): + """Only candidates that reach the changed env (directly or transitively) fire.""" + changed_env = _make_changed_env() + sync_with_ref = _sync("env-with-ref") + sync_without_ref = _sync("env-without-ref") + mock_get_model.side_effect = _mock_models([sync_with_ref, sync_without_ref]) + + graph = { + "env-with-ref": {"changed-env-id"}, + "env-without-ref": {"unrelated-env"}, + "unrelated-env": set(), + } + with _patch_refs(graph): trigger_syncs_for_referencing_envs(changed_env) mock_trigger_sync.assert_called_once_with(sync_with_ref) diff --git a/backend/tests/utils/test_secret.py b/backend/tests/utils/test_secret.py index 4a232d2be..d9ab0d244 100644 --- a/backend/tests/utils/test_secret.py +++ b/backend/tests/utils/test_secret.py @@ -2,16 +2,47 @@ import pytest from pathlib import Path import logging -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, ANY from backend.utils.secrets import get_secret from api.utils.secrets import ( normalize_path_string, decompose_path_and_key, decrypt_secret_value, - env_has_references_to, + get_referenced_environment_ids, + CROSS_APP_ENV_PATTERN, + CROSS_ENV_PATTERN, + LOCAL_REF_PATTERN, ) +# --- reference pattern matching --- +# A dot-less local ref placed before a dotted ref must not make the dotted +# pattern span across the local ref's braces (regression for combined refs +# like "${LOCAL}+${env.KEY}"). + + +def test_reference_patterns_do_not_span_adjacent_references(): + # local then cross-env: cross-env must match ONLY the dotted ref + assert CROSS_ENV_PATTERN.findall("${LOCAL}+${staging.HOST}") == [ + ("staging", "HOST") + ] + # local then cross-app: cross-app must match ONLY the dotted ref + assert CROSS_APP_ENV_PATTERN.findall("${LOCAL}+${backend::prod.KEY}") == [ + ("backend", "prod", "KEY") + ] + # local pattern must not swallow the dotted ref that follows it + assert LOCAL_REF_PATTERN.findall("${A}+${staging.HOST}") == ["A"] + # all three types combined in one value resolve to three distinct matches + combo = "${L2}|${prod.DB}|${app::prod.KEY}" + assert LOCAL_REF_PATTERN.findall(combo) == ["L2"] + assert CROSS_ENV_PATTERN.findall(combo) == [("prod", "DB")] + assert CROSS_APP_ENV_PATTERN.findall(combo) == [("app", "prod", "KEY")] + # folder-qualified keys (containing "/") still resolve + assert CROSS_ENV_PATTERN.findall("${prod./db/KEY}") == [("prod", "/db/KEY")] + # double-brace (Railway) syntax is still ignored + assert LOCAL_REF_PATTERN.findall("${{RAILWAY}}") == [] + + @pytest.fixture def temp_secret_file(tmp_path): """Create a temporary file containing a secret""" @@ -251,7 +282,16 @@ def get_model_side_effect(app_label, model_name): result = decrypt_secret_value(secret) assert result == "Value is secret_api_key" - mock_resolve.assert_called_with(mock_env, "/", "API_KEY", crypto_context=None) + mock_resolve.assert_called_with( + mock_env, + "/", + "API_KEY", + crypto_context=None, + require_resolved_references=False, + account=None, + context_cache=None, + _visited=ANY, + ) @patch("api.utils.secrets.apps.get_model") @@ -290,7 +330,14 @@ def get_model_side_effect(app_label, model_name): assert result == "Value is secret_api_key" mock_resolve.assert_called_with( - mock_env, "/backend", "API_KEY", crypto_context=None + mock_env, + "/backend", + "API_KEY", + crypto_context=None, + require_resolved_references=False, + account=None, + context_cache=None, + _visited=ANY, ) @@ -319,278 +366,344 @@ def test_decrypt_secret_value_ignores_railway_syntax( assert result == "Some value with ${{RAILWAY_REF}}" -# --- env_has_references_to tests --- - - +@patch("api.utils.secrets.blake2b_digest") @patch("api.utils.secrets.apps.get_model") @patch("api.utils.secrets.decrypt_asymmetric") -@patch("api.utils.secrets.env_keypair") -@patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_cross_env( - mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +@patch("api.utils.secrets.get_environment_crypto_context") +def test_decrypt_secret_value_resolves_nested_local_references( + mock_get_context, mock_decrypt, mock_get_model, mock_digest ): - """Test detecting ${ENV.KEY} cross-env reference""" - mock_server_kp.return_value = (b"pk", b"sk") - mock_env_kp.return_value = (b"env_pub", b"env_priv") + """Multi-level local references resolve fully (regression test for #877). - # Mock models - mock_env = MagicMock() - mock_env.app_id = "app-1" + LEVEL2 -> ${LEVEL1} -> ${LEVEL0}; the final value must be fully expanded, + not just resolved one level deep. + """ + mock_get_context.return_value = (b"salt", b"pub", b"priv") + mock_digest.side_effect = lambda key_name, salt: key_name # key_digest == key name - mock_server_env_key = MagicMock() - mock_server_env_key.wrapped_seed = "wrapped_seed" + env = MagicMock() + env.id = 1 - MockEnvironment = MagicMock() - MockEnvironment.objects.select_related.return_value.get.return_value = mock_env - MockEnvironment.DoesNotExist = Exception + def make_secret(digest, ciphertext): + s = MagicMock() + s.environment_id = 1 + s.environment = env + s.path = "/" + s.key_digest = digest + s.value = ciphertext + return s - MockServerEnvKey = MagicMock() - MockServerEnvKey.objects.get.return_value = mock_server_env_key - MockServerEnvKey.DoesNotExist = Exception + level2 = make_secret("LEVEL2", "ct:L2") + level1 = make_secret("LEVEL1", "ct:L1") + level0 = make_secret("LEVEL0", "ct:L0") - mock_secret = MagicMock() - mock_secret.value = "encrypted" + decrypt_map = { + "ct:L2": "${LEVEL1}+L2", + "ct:L1": "${LEVEL0}+L1", + "ct:L0": "L0", + } + mock_decrypt.side_effect = lambda ct, *a, **k: decrypt_map[ct] + + secrets_by_digest = {"LEVEL1": level1, "LEVEL0": level0} MockSecret = MagicMock() - MockSecret.objects.filter.return_value = [mock_secret] + MockSecret.objects.get.side_effect = lambda **kwargs: secrets_by_digest[ + kwargs["key_digest"] + ] def get_model_side_effect(app_label, model_name): if model_name == "Secret": return MockSecret - if model_name == "ServerEnvironmentKey": - return MockServerEnvKey - if model_name == "Environment": - return MockEnvironment return MagicMock() mock_get_model.side_effect = get_model_side_effect - # decrypt_asymmetric: first call for seed, second for secret value - mock_decrypt.side_effect = ["env_seed", "url=${staging.DB_HOST}"] - - result = env_has_references_to("env-1", "staging", "my-app", "app-1") - assert result is True + result = decrypt_secret_value(level2) + assert result == "L0+L1+L2" +@patch("api.utils.secrets.blake2b_digest") +@patch("api.utils.secrets.check_environment_access") +@patch("api.utils.secrets.get_or_compute_crypto_context") @patch("api.utils.secrets.apps.get_model") @patch("api.utils.secrets.decrypt_asymmetric") -@patch("api.utils.secrets.env_keypair") -@patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_cross_app( - mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +@patch("api.utils.secrets.get_environment_crypto_context") +def test_decrypt_secret_value_resolves_nested_cross_env_reference( + mock_get_context, + mock_decrypt, + mock_get_model, + mock_get_or_compute, + mock_check_access, + mock_digest, ): - """Test detecting ${APP::ENV.KEY} cross-app reference""" - mock_server_kp.return_value = (b"pk", b"sk") - mock_env_kp.return_value = (b"env_pub", b"env_priv") - - mock_env = MagicMock() - mock_env.app_id = "app-2" # Different app - - mock_server_env_key = MagicMock() - mock_server_env_key.wrapped_seed = "wrapped_seed" - - MockEnvironment = MagicMock() - MockEnvironment.objects.select_related.return_value.get.return_value = mock_env - MockEnvironment.DoesNotExist = Exception - - MockServerEnvKey = MagicMock() - MockServerEnvKey.objects.get.return_value = mock_server_env_key - MockServerEnvKey.DoesNotExist = Exception - - mock_secret = MagicMock() - mock_secret.value = "encrypted" + """A cross-env reference whose target value itself contains a reference is + resolved recursively: ${staging.DB_URL} -> ${staging.HOST}:5432 -> db:5432. + """ + crypto_context = (b"salt", b"pub", b"priv") + mock_get_context.return_value = crypto_context + mock_get_or_compute.return_value = crypto_context + mock_check_access.return_value = True + mock_digest.side_effect = lambda key_name, salt: key_name + + main_env = MagicMock() + main_env.id = 1 + staging_env = MagicMock() + staging_env.id = 2 + + def make_secret(env, env_id, digest, ciphertext): + s = MagicMock() + s.environment = env + s.environment_id = env_id + s.path = "/" + s.key_digest = digest + s.value = ciphertext + return s + + main_secret = make_secret(main_env, 1, "MAIN", "ct:MAIN") + db_url_secret = make_secret(staging_env, 2, "DB_URL", "ct:DBURL") + host_secret = make_secret(staging_env, 2, "HOST", "ct:HOST") + + decrypt_map = { + "ct:MAIN": "${staging.DB_URL}", + "ct:DBURL": "${staging.HOST}:5432", + "ct:HOST": "db", + } + mock_decrypt.side_effect = lambda ct, *a, **k: decrypt_map[ct] + + secrets_by_digest = {"DB_URL": db_url_secret, "HOST": host_secret} MockSecret = MagicMock() - MockSecret.objects.filter.return_value = [mock_secret] + MockSecret.objects.get.side_effect = lambda **kwargs: secrets_by_digest[ + kwargs["key_digest"] + ] + MockEnvironment = MagicMock() + MockEnvironment.objects.get.return_value = staging_env def get_model_side_effect(app_label, model_name): if model_name == "Secret": return MockSecret - if model_name == "ServerEnvironmentKey": - return MockServerEnvKey if model_name == "Environment": return MockEnvironment return MagicMock() mock_get_model.side_effect = get_model_side_effect - mock_decrypt.side_effect = ["env_seed", "url=${backend::production.API_KEY}"] - - result = env_has_references_to("env-2", "production", "backend", "app-1") - assert result is True + result = decrypt_secret_value(main_secret) + assert result == "db:5432" +@patch("api.utils.secrets.blake2b_digest") @patch("api.utils.secrets.apps.get_model") @patch("api.utils.secrets.decrypt_asymmetric") -@patch("api.utils.secrets.env_keypair") -@patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_no_match( - mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +@patch("api.utils.secrets.get_environment_crypto_context") +def test_decrypt_secret_value_breaks_reference_cycle( + mock_get_context, mock_decrypt, mock_get_model, mock_digest ): - """Test that no reference is detected when values don't reference the target""" - mock_server_kp.return_value = (b"pk", b"sk") - mock_env_kp.return_value = (b"env_pub", b"env_priv") - - mock_env = MagicMock() - mock_env.app_id = "app-1" + """A reference cycle (A -> B -> A) terminates instead of recursing forever.""" + mock_get_context.return_value = (b"salt", b"pub", b"priv") + mock_digest.side_effect = lambda key_name, salt: key_name - mock_server_env_key = MagicMock() - mock_server_env_key.wrapped_seed = "wrapped_seed" + env = MagicMock() + env.id = 1 - MockEnvironment = MagicMock() - MockEnvironment.objects.select_related.return_value.get.return_value = mock_env - MockEnvironment.DoesNotExist = Exception + def make_secret(digest, ciphertext): + s = MagicMock() + s.environment_id = 1 + s.environment = env + s.path = "/" + s.key_digest = digest + s.value = ciphertext + return s - MockServerEnvKey = MagicMock() - MockServerEnvKey.objects.get.return_value = mock_server_env_key - MockServerEnvKey.DoesNotExist = Exception + a = make_secret("A", "ct:A") + b = make_secret("B", "ct:B") - mock_secret = MagicMock() - mock_secret.value = "encrypted" + decrypt_map = {"ct:A": "${B}", "ct:B": "${A}"} + mock_decrypt.side_effect = lambda ct, *args, **kwargs: decrypt_map[ct] + secrets_by_digest = {"A": a, "B": b} MockSecret = MagicMock() - MockSecret.objects.filter.return_value = [mock_secret] + MockSecret.objects.get.side_effect = lambda **kwargs: secrets_by_digest[ + kwargs["key_digest"] + ] def get_model_side_effect(app_label, model_name): if model_name == "Secret": return MockSecret - if model_name == "ServerEnvironmentKey": - return MockServerEnvKey - if model_name == "Environment": - return MockEnvironment return MagicMock() mock_get_model.side_effect = get_model_side_effect - mock_decrypt.side_effect = ["env_seed", "just a plain value"] + # Should return without raising / hanging; the cyclic ref is left unresolved. + result = decrypt_secret_value(a) + assert result == "${A}" - result = env_has_references_to("env-1", "staging", "my-app", "app-1") - assert result is False +# --- get_referenced_environment_ids tests --- -@patch("api.utils.secrets.apps.get_model") -@patch("api.utils.secrets.decrypt_asymmetric") -@patch("api.utils.secrets.env_keypair") -@patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_no_sse( - mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model -): - """Test that False is returned when source env has no ServerEnvironmentKey (no SSE)""" - mock_server_kp.return_value = (b"pk", b"sk") +def _refs_models(source_app_id="app-1", server_key_exists=True): + """get_model side_effect for get_referenced_environment_ids with a single + secret in the source env. Reference text is supplied via decrypt_asymmetric + mocking in each test.""" mock_env = MagicMock() + mock_env.app_id = source_app_id + MockEnvironment = MagicMock() MockEnvironment.objects.select_related.return_value.get.return_value = mock_env MockEnvironment.DoesNotExist = Exception MockServerEnvKey = MagicMock() MockServerEnvKey.DoesNotExist = type("DoesNotExist", (Exception,), {}) - MockServerEnvKey.objects.get.side_effect = MockServerEnvKey.DoesNotExist() + if server_key_exists: + mock_server_env_key = MagicMock() + mock_server_env_key.wrapped_seed = "wrapped_seed" + MockServerEnvKey.objects.get.return_value = mock_server_env_key + else: + MockServerEnvKey.objects.get.side_effect = MockServerEnvKey.DoesNotExist() + + mock_secret = MagicMock() + mock_secret.value = "encrypted" + MockSecret = MagicMock() + MockSecret.objects.filter.return_value = [mock_secret] def get_model_side_effect(app_label, model_name): + if model_name == "Secret": + return MockSecret if model_name == "ServerEnvironmentKey": return MockServerEnvKey if model_name == "Environment": return MockEnvironment return MagicMock() - mock_get_model.side_effect = get_model_side_effect - - result = env_has_references_to("env-1", "staging", "my-app", "app-1") - assert result is False + return get_model_side_effect @patch("api.utils.secrets.apps.get_model") @patch("api.utils.secrets.decrypt_asymmetric") @patch("api.utils.secrets.env_keypair") @patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_case_insensitive( +def test_get_referenced_environment_ids_cross_env( mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model ): - """Test that reference matching is case-insensitive""" + """${ENV.KEY} resolves to the target env id within the same app.""" mock_server_kp.return_value = (b"pk", b"sk") mock_env_kp.return_value = (b"env_pub", b"env_priv") + mock_get_model.side_effect = _refs_models(source_app_id="app-1") + mock_decrypt.side_effect = ["env_seed", "url=${staging.DB_HOST}"] - mock_env = MagicMock() - mock_env.app_id = "app-1" + name_ctx = { + "apps_by_name": {}, + "ambiguous_apps": set(), + "envs_by_app_name": {("app-1", "staging"): "env-staging-id"}, + } - mock_server_env_key = MagicMock() - mock_server_env_key.wrapped_seed = "wrapped_seed" + assert get_referenced_environment_ids("env-1", name_ctx) == {"env-staging-id"} - MockEnvironment = MagicMock() - MockEnvironment.objects.select_related.return_value.get.return_value = mock_env - MockEnvironment.DoesNotExist = Exception - MockServerEnvKey = MagicMock() - MockServerEnvKey.objects.get.return_value = mock_server_env_key - MockServerEnvKey.DoesNotExist = Exception +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_get_referenced_environment_ids_cross_app( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """${APP::ENV.KEY} resolves the app name then the env id.""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + mock_get_model.side_effect = _refs_models(source_app_id="app-2") + mock_decrypt.side_effect = ["env_seed", "${backend::production.API_KEY}"] - mock_secret = MagicMock() - mock_secret.value = "encrypted" + name_ctx = { + "apps_by_name": {"backend": "app-1"}, + "ambiguous_apps": set(), + "envs_by_app_name": {("app-1", "production"): "env-prod-id"}, + } - MockSecret = MagicMock() - MockSecret.objects.filter.return_value = [mock_secret] + assert get_referenced_environment_ids("env-2", name_ctx) == {"env-prod-id"} - def get_model_side_effect(app_label, model_name): - if model_name == "Secret": - return MockSecret - if model_name == "ServerEnvironmentKey": - return MockServerEnvKey - if model_name == "Environment": - return MockEnvironment - return MagicMock() - mock_get_model.side_effect = get_model_side_effect +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_get_referenced_environment_ids_no_match( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """A plain value references nothing.""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + mock_get_model.side_effect = _refs_models(source_app_id="app-1") + mock_decrypt.side_effect = ["env_seed", "just a plain value"] - mock_decrypt.side_effect = ["env_seed", "url=${STAGING.DB_HOST}"] + name_ctx = { + "apps_by_name": {}, + "ambiguous_apps": set(), + "envs_by_app_name": {("app-1", "staging"): "env-staging-id"}, + } - result = env_has_references_to("env-1", "staging", "my-app", "app-1") - assert result is True + assert get_referenced_environment_ids("env-1", name_ctx) == set() @patch("api.utils.secrets.apps.get_model") @patch("api.utils.secrets.decrypt_asymmetric") @patch("api.utils.secrets.env_keypair") @patch("api.utils.secrets.get_server_keypair") -def test_env_has_references_to_ignores_railway_syntax( +def test_get_referenced_environment_ids_ambiguous_app_skipped( mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model ): - """Test that ${{...}} Railway-style syntax is not treated as a reference""" + """A cross-app reference to an ambiguously-named app is not resolved.""" mock_server_kp.return_value = (b"pk", b"sk") mock_env_kp.return_value = (b"env_pub", b"env_priv") + mock_get_model.side_effect = _refs_models(source_app_id="app-2") + mock_decrypt.side_effect = ["env_seed", "${backend::production.API_KEY}"] - mock_env = MagicMock() - mock_env.app_id = "app-1" - - mock_server_env_key = MagicMock() - mock_server_env_key.wrapped_seed = "wrapped_seed" + name_ctx = { + "apps_by_name": {"backend": "app-1"}, + "ambiguous_apps": {"backend"}, + "envs_by_app_name": {("app-1", "production"): "env-prod-id"}, + } - MockEnvironment = MagicMock() - MockEnvironment.objects.select_related.return_value.get.return_value = mock_env - MockEnvironment.DoesNotExist = Exception + assert get_referenced_environment_ids("env-2", name_ctx) == set() - MockServerEnvKey = MagicMock() - MockServerEnvKey.objects.get.return_value = mock_server_env_key - MockServerEnvKey.DoesNotExist = Exception - mock_secret = MagicMock() - mock_secret.value = "encrypted" +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_get_referenced_environment_ids_no_sse( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """No ServerEnvironmentKey (SSE disabled) => no references discoverable.""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_get_model.side_effect = _refs_models(server_key_exists=False) - MockSecret = MagicMock() - MockSecret.objects.filter.return_value = [mock_secret] + name_ctx = { + "apps_by_name": {}, + "ambiguous_apps": set(), + "envs_by_app_name": {("app-1", "staging"): "env-staging-id"}, + } - def get_model_side_effect(app_label, model_name): - if model_name == "Secret": - return MockSecret - if model_name == "ServerEnvironmentKey": - return MockServerEnvKey - if model_name == "Environment": - return MockEnvironment - return MagicMock() + assert get_referenced_environment_ids("env-1", name_ctx) == set() - mock_get_model.side_effect = get_model_side_effect +@patch("api.utils.secrets.apps.get_model") +@patch("api.utils.secrets.decrypt_asymmetric") +@patch("api.utils.secrets.env_keypair") +@patch("api.utils.secrets.get_server_keypair") +def test_get_referenced_environment_ids_ignores_railway_syntax( + mock_server_kp, mock_env_kp, mock_decrypt, mock_get_model +): + """${{...}} Railway-style syntax is not treated as a reference.""" + mock_server_kp.return_value = (b"pk", b"sk") + mock_env_kp.return_value = (b"env_pub", b"env_priv") + mock_get_model.side_effect = _refs_models(source_app_id="app-1") mock_decrypt.side_effect = ["env_seed", "url=${{staging.DB_HOST}}"] - result = env_has_references_to("env-1", "staging", "my-app", "app-1") - assert result is False + name_ctx = { + "apps_by_name": {}, + "ambiguous_apps": set(), + "envs_by_app_name": {("app-1", "staging"): "env-staging-id"}, + } + + assert get_referenced_environment_ids("env-1", name_ctx) == set() diff --git a/frontend/apollo/schema.graphql b/frontend/apollo/schema.graphql index 9337dc5e5..dbd821041 100644 --- a/frontend/apollo/schema.graphql +++ b/frontend/apollo/schema.graphql @@ -475,18 +475,6 @@ enum ApiSecretEventTypeChoices { CONFIG } -"""An enumeration.""" -enum ApiSecretEventTypeChoices { - """Secret""" - SECRET - - """Sealed""" - SEALED - - """Config""" - CONFIG -} - """An enumeration.""" enum ApiSecretEventEventTypeChoices { """Create""" diff --git a/frontend/components/syncing/SyncManagement.tsx b/frontend/components/syncing/SyncManagement.tsx index 47ca8583b..e21a85421 100644 --- a/frontend/components/syncing/SyncManagement.tsx +++ b/frontend/components/syncing/SyncManagement.tsx @@ -86,7 +86,9 @@ export const SyncManagement = (props: { sync: EnvironmentSyncType; closeModal?: } } - const isSyncing = sync.status === ApiEnvironmentSyncStatusChoices.InProgress + const isSyncing = + sync.status === ApiEnvironmentSyncStatusChoices.InProgress || + sync.status === ApiEnvironmentSyncStatusChoices.Queued const userCanReadCredentials = hasPermission('IntegrationCredentials', 'read') const userCanUpdateSyncs = hasPermission('Integrations', 'update', true) @@ -158,6 +160,8 @@ export const SyncManagement = (props: { sync: EnvironmentSyncType; closeModal?:
{sync.status !== ApiEnvironmentSyncStatusChoices.InProgress && + sync.status !== ApiEnvironmentSyncStatusChoices.Queued && + sync.lastSync && relativeTimeFromDates(new Date(sync.lastSync))}
diff --git a/frontend/tests/utils/secretReferences.test.ts b/frontend/tests/utils/secretReferences.test.ts index 75ab87295..07b17d44d 100644 --- a/frontend/tests/utils/secretReferences.test.ts +++ b/frontend/tests/utils/secretReferences.test.ts @@ -137,6 +137,49 @@ describe('parseAllReferences', () => { expect(refs[1].env).toBe('staging') }) + test('does not span a dotted ref across a preceding local ref', () => { + // Regression: a dot-less local ref before a dotted ref must not make the + // dotted pattern swallow the local ref's braces (which produced overlapping + // refs and a doubled highlight overlay). + const refs = parseAllReferences('${DEBUG}-${Production.DEBUG}') + expect(refs).toHaveLength(2) + expect(refs[0]).toMatchObject({ type: 'local', pathAndKey: 'DEBUG' }) + expect(refs[1]).toMatchObject({ + type: 'cross-env', + env: 'Production', + pathAndKey: 'DEBUG', + }) + }) + + test('does not span a cross-app ref across a preceding local ref', () => { + const refs = parseAllReferences('${LOCAL}-${OtherApp::production.SECRET}') + expect(refs).toHaveLength(2) + expect(refs[0]).toMatchObject({ type: 'local', pathAndKey: 'LOCAL' }) + expect(refs[1]).toMatchObject({ + type: 'cross-app', + app: 'OtherApp', + env: 'production', + pathAndKey: 'SECRET', + }) + }) + + test('parses all three reference types combined in one value', () => { + const refs = parseAllReferences( + '${L2}|${prod.DB}|${app::prod.KEY}' + ) + expect(refs.map((r) => r.type)).toEqual(['local', 'cross-env', 'cross-app']) + }) + + test('highlight overlay reconstructs combined value exactly (no doubling)', () => { + // The overlay (segmentSecretValue) must reproduce the input verbatim when + // segments are concatenated — overlapping refs previously doubled it. + const value = '${DEBUG}-${Production.DEBUG}' + const rebuilt = segmentSecretValue(value) + .map((s) => s.text) + .join('') + expect(rebuilt).toBe(value) + }) + test('excludes Railway double-brace syntax ${{...}}', () => { const refs = parseAllReferences('${{RAILWAY_VAR}}') expect(refs).toHaveLength(0) diff --git a/frontend/utils/secretReferences.ts b/frontend/utils/secretReferences.ts index 941810b86..cc5286d47 100644 --- a/frontend/utils/secretReferences.ts +++ b/frontend/utils/secretReferences.ts @@ -93,14 +93,21 @@ export function secretIdKey(envName: string, path: string, keyName: string): str // --- Regex patterns (matching backend) --- +// The capture groups exclude `{` and `}` so a single reference can never span +// across an adjacent ${...} reference. Without this, a dot-less local ref placed +// before a dotted ref (e.g. "${LOCAL}-${env.KEY}") makes the dotted pattern's +// leading group greedily consume up to the later dot, producing a bogus match +// (and overlapping refs that double the highlight overlay). Mirrors the backend +// patterns in backend/api/utils/secrets.py. + // Cross-app: ${app::env.path/KEY} — must contain :: -const CROSS_APP_RE = /\$\{(?!\{)(.+?)::(.+?)\.(.+?)\}/g +const CROSS_APP_RE = /\$\{(?!\{)([^{}]+?)::([^{}]+?)\.([^{}]+?)\}/g // Cross-env: ${env.path/KEY} — must contain . but not :: -const CROSS_ENV_RE = /\$\{(?!\{)(?![^{]*::)([^.]+?)\.(.+?)\}/g +const CROSS_ENV_RE = /\$\{(?!\{)(?![^{}]*::)([^{}.]+?)\.([^{}]+?)\}/g // Local: ${KEY} or ${path/KEY} — no . allowed -const LOCAL_REF_RE = /\$\{(?!\{)([^.]+?)\}/g +const LOCAL_REF_RE = /\$\{(?!\{)([^{}.]+?)\}/g // --- Parsing --- From 4bfefc87fefa6382d55f05a73c5b9b222b5efe62 Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 23 Jun 2026 21:45:07 +0530 Subject: [PATCH 11/12] fix(secrets): record override-less bulk PUT updates and access-check override references --- backend/api/serializers.py | 10 ++- backend/api/views/secrets.py | 2 +- backend/tests/api/views/test_secrets_api.py | 73 +++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/backend/api/serializers.py b/backend/api/serializers.py index 302b1c6c9..d6d39113d 100644 --- a/backend/api/serializers.py +++ b/backend/api/serializers.py @@ -163,11 +163,18 @@ def get_value(self, obj): secret_obj = obj.secret secret_obj.value = obj.value + account = self.context.get("account") crypto_context = self.context.get("crypto_context") context_cache = self.context.get("context_cache") + # Pass account so references inside a personal override enforce the + # caller's access to the referenced environment (same as the parent + # SecretSerializer), rather than resolving unconditionally. value = decrypt_secret_value( - secret_obj, crypto_context=crypto_context, context_cache=context_cache + secret_obj, + account=account, + crypto_context=crypto_context, + context_cache=context_cache, ) return value return obj.value @@ -245,6 +252,7 @@ def get_override(self, obj): personal_secret, context={ "sse": self.context.get("sse"), + "account": self.context.get("account"), "crypto_context": self.context.get("crypto_context"), "context_cache": self.context.get("context_cache"), }, diff --git a/backend/api/views/secrets.py b/backend/api/views/secrets.py index 606bcf584..01d7d4762 100644 --- a/backend/api/views/secrets.py +++ b/backend/api/views/secrets.py @@ -1191,7 +1191,7 @@ def put(self, request, *args, **kwargs): }, ) - updated_secrets.append(secret_obj) + updated_secrets.append(secret_obj) finally: if updated_secrets: env.save() diff --git a/backend/tests/api/views/test_secrets_api.py b/backend/tests/api/views/test_secrets_api.py index ecda7d74c..b3d219fb1 100644 --- a/backend/tests/api/views/test_secrets_api.py +++ b/backend/tests/api/views/test_secrets_api.py @@ -191,3 +191,76 @@ def test_delete_filter_scoped_by_environment(self, _ip, _throttle, _audit): ) assert candidate_call.kwargs.get("environment") == self.env assert candidate_call.kwargs.get("deleted_at__isnull") is True + + +def _build_put_request(env, body): + factory = APIRequestFactory() + user = _make_user() + request = factory.put( + "/public/v1/secrets/", data=json.dumps(body), content_type="application/json" + ) + force_authenticate(request, user=user, token=_make_auth(env, user)) + return request + + +# ════════════════════════════════════════════════════════════════════ +# PublicSecretsView.put — bulk update must record override-less secrets +# ════════════════════════════════════════════════════════════════════ + + +class TestPublicSecretsPutRecordsOverridelessUpdates: + """Regression: a bulk PUT of a secret with no personal override must still + be appended to `updated_secrets` — so it's returned, audit-logged, and the + environment sync is triggered once. A prior indentation bug nested the + append inside the `if override` branch, silently dropping these updates.""" + + @pytest.fixture(autouse=True) + def setup(self, settings): + self.view = PublicSecretsView.as_view() + self.org = _make_org() + self.app = _make_app(org=self.org, sse_enabled=True) + self.env = _make_env(app=self.app) + + @patch("api.views.secrets.SecretSerializer") + @patch( + "api.views.secrets.get_environment_crypto_context", + return_value=(b"salt", b"pub", b"priv"), + ) + @patch("api.views.secrets.log_secret_events_bulk") + @patch("api.views.secrets.encrypt_asymmetric", return_value="ph:v1:enc") + @patch("api.views.secrets.get_environment_keys", return_value=(b"pub", b"priv")) + @patch("api.views.secrets.PlanBasedRateThrottle.allow_request", return_value=True) + @patch("api.views.secrets.IsIPAllowed.has_permission", return_value=True) + def test_put_overrideless_secret_is_recorded_logged_and_triggers_sync( + self, _ip, _throttle, _keys, _enc, mock_audit, _ctx, mock_serializer + ): + secret_id = str(uuid.uuid4()) + secret_obj = Mock() + secret_obj.id = secret_id + secret_obj.rotating_secret_id = None + secret_obj.type = "secret" + secret_obj.version = 1 + secret_obj.key = "ph:v1:k" + secret_obj.key_digest = "digest" + secret_obj.comment = "ph:v1:c" + secret_obj.value = "ph:v1:v" + secret_obj.save = Mock() + + mock_serializer.return_value.data = [{"id": secret_id, "value": "v2"}] + + with patch( + "api.views.secrets.Secret.objects.get", return_value=secret_obj + ): + # No "override" key — the common CLI/SDK update. + request = _build_put_request( + self.env, {"secrets": [{"id": secret_id, "value": "v2"}]} + ) + response = self.view(request) + + assert response.status_code == status.HTTP_200_OK + # Written with per-secret triggering deferred... + secret_obj.save.assert_called_once_with(trigger_sync=False) + # ...the env sync fired exactly once afterwards (only happens if appended)... + self.env.save.assert_called_once() + # ...and the update was audit-logged in a non-empty batch. + assert secret_obj in mock_audit.call_args.args[0] From 8211d98e27f01783b5631d8c0c7d21a70c16cc9a Mon Sep 17 00:00:00 2001 From: rohan Date: Wed, 24 Jun 2026 19:01:52 +0530 Subject: [PATCH 12/12] perf(syncing): run cross-reference sync detection off the request path --- backend/api/models.py | 10 +++----- backend/api/tasks/syncing.py | 28 +++++++++++++++++++-- backend/tests/tasks/test_syncing.py | 39 ++++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/backend/api/models.py b/backend/api/models.py index 03c3b42c6..32eca05da 100644 --- a/backend/api/models.py +++ b/backend/api/models.py @@ -11,7 +11,7 @@ from django.utils import timezone from django.conf import settings from api.services import Providers, ServiceConfig -from api.tasks.syncing import trigger_sync_tasks, trigger_syncs_for_referencing_envs +from api.tasks.syncing import trigger_sync_tasks, detect_and_trigger_referencing_syncs from backend.quotas import ( can_add_account, can_add_app, @@ -467,10 +467,9 @@ class Environment(models.Model): objects = EnvironmentManager() def save(self, *args, **kwargs): - # Call the "real" save() method to save the Secret super().save(*args, **kwargs) - # Trigger all sync jobs associated with this environment + # Own syncs: synchronous, so queued status shows immediately. [ trigger_sync_tasks(env_sync) for env_sync in EnvironmentSync.objects.filter( @@ -479,9 +478,8 @@ def save(self, *args, **kwargs): if env_sync.is_active ] - # Trigger syncs for other environments whose secrets - # reference this one (cross-env and cross-app references) - trigger_syncs_for_referencing_envs(self) + # Referencing envs: deferred to a worker (detection decrypts + walks the org graph). + detect_and_trigger_referencing_syncs.delay(str(self.id)) class EnvironmentKey(models.Model): diff --git a/backend/api/tasks/syncing.py b/backend/api/tasks/syncing.py index 5f6604062..12737b57f 100644 --- a/backend/api/tasks/syncing.py +++ b/backend/api/tasks/syncing.py @@ -515,8 +515,11 @@ def trigger_syncs_for_referencing_envs(changed_env): this we build the org's reference graph and follow it from each candidate environment until we reach the changed environment (cycle-safe). - Called synchronously from Environment.save() so the pending sync status is - reflected in the UI right away; the actual sync work is dispatched async. + Runs off the request path, dispatched from Environment.save() via the + detect_and_trigger_referencing_syncs RQ job — so referencing envs' syncs are + queued shortly after the write, not within it. (The changed environment's own + syncs are still triggered synchronously in Environment.save for immediate + status.) The actual provider sync work is dispatched async by trigger_sync_tasks. """ from api.utils.secrets import get_referenced_environment_ids @@ -602,3 +605,24 @@ def references_changed_env(start_env_id): logger.warning( f"Failed to check references for environment {env_id}: {e}" ) + + +@job("default", timeout=DEFAULT_TIMEOUT) +def detect_and_trigger_referencing_syncs(changed_env_id): + """ + Async entrypoint for trigger_syncs_for_referencing_envs. + + Dispatched from Environment.save() so the reference-graph detection — which + decrypts and scans every synced/intermediate environment in the org — runs + off the request path instead of blocking the secret write. The changed + environment's own syncs are still triggered synchronously by Environment.save + for immediate status feedback; only the cross-reference fan-out is deferred. + """ + Environment = apps.get_model("api", "Environment") + try: + changed_env = Environment.objects.select_related( + "app", "app__organisation" + ).get(id=changed_env_id) + except Environment.DoesNotExist: + return + trigger_syncs_for_referencing_envs(changed_env) diff --git a/backend/tests/tasks/test_syncing.py b/backend/tests/tasks/test_syncing.py index fd4a93936..1b59a4856 100644 --- a/backend/tests/tasks/test_syncing.py +++ b/backend/tests/tasks/test_syncing.py @@ -1,5 +1,42 @@ from unittest.mock import patch, MagicMock -from api.tasks.syncing import trigger_syncs_for_referencing_envs +from api.tasks.syncing import ( + trigger_syncs_for_referencing_envs, + detect_and_trigger_referencing_syncs, +) + + +# --- detect_and_trigger_referencing_syncs (async wrapper) tests --- + + +@patch("api.tasks.syncing.trigger_syncs_for_referencing_envs") +@patch("api.tasks.syncing.apps.get_model") +def test_detect_and_trigger_refetches_env_and_delegates(mock_get_model, mock_trigger): + """The async job re-fetches the env by id and runs the detection.""" + env = MagicMock() + MockEnvironment = MagicMock() + MockEnvironment.objects.select_related.return_value.get.return_value = env + MockEnvironment.DoesNotExist = type("DoesNotExist", (Exception,), {}) + mock_get_model.return_value = MockEnvironment + + detect_and_trigger_referencing_syncs("env-id") + + mock_trigger.assert_called_once_with(env) + + +@patch("api.tasks.syncing.trigger_syncs_for_referencing_envs") +@patch("api.tasks.syncing.apps.get_model") +def test_detect_and_trigger_missing_env_is_noop(mock_get_model, mock_trigger): + """If the env was deleted before the job runs, it returns without error.""" + MockEnvironment = MagicMock() + MockEnvironment.DoesNotExist = type("DoesNotExist", (Exception,), {}) + MockEnvironment.objects.select_related.return_value.get.side_effect = ( + MockEnvironment.DoesNotExist() + ) + mock_get_model.return_value = MockEnvironment + + detect_and_trigger_referencing_syncs("missing-env") + + mock_trigger.assert_not_called() # --- trigger_syncs_for_referencing_envs tests ---