diff --git a/devops/production/bin/deploy.sh b/devops/production/bin/deploy.sh index 7d15ec5..76032eb 100755 --- a/devops/production/bin/deploy.sh +++ b/devops/production/bin/deploy.sh @@ -397,9 +397,9 @@ sync_runtime_env_from_infisical() { python3 "${ROOT_DIR}/bin/validate-runtime-env.py" \ "${RUNTIME_ENV_TEMP_FILE}" \ --write-operation-envs "${ROOT_DIR}/env" - chmod 600 "${RUNTIME_ENV_TEMP_FILE}" - mv -f "${RUNTIME_ENV_TEMP_FILE}" "${RUNTIME_ENV_FILE}" + rm -f "${RUNTIME_ENV_TEMP_FILE}" RUNTIME_ENV_TEMP_FILE="" + require_private_regular_file "${RUNTIME_ENV_FILE}" } validate_saved_slot_values() { diff --git a/devops/production/bin/test_validate_runtime_env.py b/devops/production/bin/test_validate_runtime_env.py new file mode 100644 index 0000000..b6346e7 --- /dev/null +++ b/devops/production/bin/test_validate_runtime_env.py @@ -0,0 +1,92 @@ +import importlib.util +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("validate-runtime-env.py") +SPEC = importlib.util.spec_from_file_location("validate_runtime_env", SCRIPT) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load {SCRIPT}") +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class ValidateRuntimeEnvTest(unittest.TestCase): + password = "a" * 64 + + def values(self) -> dict[str, str]: + return { + **VALIDATOR.EXPECTED_VALUES, + "DATABASE_URL": ( + "postgresql://opensyria_datasets_production:" + f"{'b' * 64}@infra-postgres:5432/" + "opensyria_datasets_production?schema=public" + ), + "REDIS_URL": ( + f"redis://:{self.password}@opensyria-production-redis:6379/0" + ), + } + + def run_validator(self, values: dict[str, str]) -> tuple[subprocess.CompletedProcess[str], Path]: + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + directory = Path(temporary.name) + source = directory / "source.env" + source.write_text( + "".join(f"{key}={value}\n" for key, value in sorted(values.items())), + encoding="utf-8", + ) + result = subprocess.run( + [ + "python3", + str(SCRIPT), + str(source), + "--write-operation-envs", + str(directory / "runtime"), + ], + check=False, + capture_output=True, + text=True, + ) + return result, directory + + def test_sanitizes_matching_recovery_password(self) -> None: + values = self.values() + values["REDIS_PASSWORD"] = self.password + + result, directory = self.run_validator(values) + + self.assertEqual(result.returncode, 0, result.stderr) + runtime = (directory / "runtime" / "api.env").read_text(encoding="utf-8") + self.assertIn("REDIS_URL=", runtime) + self.assertNotIn("REDIS_PASSWORD=", runtime) + + def test_accepts_already_sanitized_runtime_environment(self) -> None: + result, directory = self.run_validator(self.values()) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((directory / "runtime" / "api.env").is_file()) + + def test_rejects_mismatched_recovery_password(self) -> None: + values = self.values() + values["REDIS_PASSWORD"] = "c" * 64 + + result, _ = self.run_validator(values) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match", result.stderr) + + def test_rejects_other_unexpected_keys(self) -> None: + values = self.values() + values["UNEXPECTED_SECRET"] = "forbidden" + + result, _ = self.run_validator(values) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("unexpected or forbidden keys", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/devops/production/bin/validate-runtime-env.py b/devops/production/bin/validate-runtime-env.py index c76cb58..e055698 100755 --- a/devops/production/bin/validate-runtime-env.py +++ b/devops/production/bin/validate-runtime-env.py @@ -40,14 +40,16 @@ "THROTTLE_FREE_TIER_DAILY_LIMIT": "500", "THROTTLE_FREE_TIER_DAILY_TTL_SECONDS": "86400", } -EXPECTED_KEYS = set(EXPECTED_VALUES) | {"DATABASE_URL", "REDIS_URL"} +RUNTIME_KEYS = set(EXPECTED_VALUES) | {"DATABASE_URL", "REDIS_URL"} +OPTIONAL_SOURCE_KEYS = {"REDIS_PASSWORD"} DATABASE_URL_PATTERN = re.compile( r"postgresql://opensyria_datasets_production:[0-9a-f]{64}" r"@infra-postgres:5432/opensyria_datasets_production\?schema=public" ) REDIS_URL_PATTERN = re.compile( - r"redis://:[0-9a-f]{64}@opensyria-production-redis:6379/0" + r"redis://:(?P[0-9a-f]{64})@opensyria-production-redis:6379/0" ) +REDIS_PASSWORD_PATTERN = re.compile(r"[0-9a-f]{64}") DATASET_KEYS = { "DATASETS_RELEASES_DIR", "DATASETS_RELEASE_SOURCES_FILE", @@ -93,9 +95,21 @@ def validate_database_url(value: str) -> None: fail("DATABASE_URL does not target the isolated OpenSyria production database") -def validate_redis_url(value: str) -> None: - if not REDIS_URL_PATTERN.fullmatch(value): +def validate_redis_url(value: str) -> str: + match = REDIS_URL_PATTERN.fullmatch(value) + if not match: fail("REDIS_URL does not target the dedicated OpenSyria production Redis database") + return match.group("password") + + +def validate_redis_password(values: dict[str, str], url_password: str) -> None: + password = values.get("REDIS_PASSWORD") + if password is None: + return + if not REDIS_PASSWORD_PATTERN.fullmatch(password): + fail("REDIS_PASSWORD is not a 64-character hexadecimal secret") + if password != url_password: + fail("REDIS_PASSWORD does not match the credential in REDIS_URL") def write_subset(directory: Path, name: str, values: dict[str, str]) -> None: @@ -120,6 +134,7 @@ def write_operation_envs(directory: Path, values: dict[str, str]) -> None: fail("operation environment directory must be a real directory") directory.chmod(0o700) + write_subset(directory, "api.env", {key: values[key] for key in RUNTIME_KEYS}) write_subset(directory, "migrate.env", {"DATABASE_URL": values["DATABASE_URL"]}) write_subset(directory, "datasets.env", {key: values[key] for key in DATASET_KEYS}) write_subset( @@ -151,8 +166,8 @@ def main() -> None: fail("the exported dotenv file must be a non-empty regular file") values = parse_dotenv(path) - missing = sorted(EXPECTED_KEYS - set(values)) - unexpected = sorted(set(values) - EXPECTED_KEYS) + missing = sorted(RUNTIME_KEYS - set(values)) + unexpected = sorted(set(values) - RUNTIME_KEYS - OPTIONAL_SOURCE_KEYS) if missing: fail(f"missing keys: {', '.join(missing)}") if unexpected: @@ -165,7 +180,8 @@ def main() -> None: fail(f"incorrect values for: {', '.join(sorted(mismatched))}") validate_database_url(values["DATABASE_URL"]) - validate_redis_url(values["REDIS_URL"]) + redis_password = validate_redis_url(values["REDIS_URL"]) + validate_redis_password(values, redis_password) if len(sys.argv) == 4: if sys.argv[2] != "--write-operation-envs": diff --git a/docs/deployment.md b/docs/deployment.md index 7a35ba1..1811484 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -125,6 +125,7 @@ DATABASE_ENABLED=true DATABASE_REQUIRED=true DATABASE_LOG_QUERIES=false REDIS_URL=redis://:@opensyria-production-redis:6379/0 +REDIS_PASSWORD= REDIS_ENABLED=true REDIS_REQUIRED=true CACHE_TTL_SECONDS=300 @@ -132,6 +133,12 @@ THROTTLE_FREE_TIER_DAILY_LIMIT=500 THROTTLE_FREE_TIER_DAILY_TTL_SECONDS=86400 ``` +`REDIS_PASSWORD` is retained in Infisical for the host recovery contract. The +deployment validates that it matches the credential embedded in `REDIS_URL`, +then omits the standalone password from `env/api.env` and every long-lived API +container. A restored, already-sanitized `env/api.env` therefore contains only +`REDIS_URL`. + `APP_RELEASE` is deliberately absent: Compose injects the full Git commit SHA for each deployment. `GITHUB_TOKEN` is deliberately absent because only the ephemeral sync job receives it. Percent-encode the database username/password diff --git a/package.json b/package.json index d84edba..2c41930 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", + "test:production-contract": "python3 -m unittest discover -s devops/production/bin -p 'test_*.py'", "test:integration:db": "pnpm run db:migrate:deploy && node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./test/jest-integration.json --runInBand", "datasets:sync": "pnpm run build && node dist/cli/sync-dataset-releases.js", "datasets:sync:prod": "node dist/cli/sync-dataset-releases.js", @@ -54,7 +55,7 @@ "release:check:config": "node scripts/check-release.js --config-only", "release:check:public-api-bridge": "node scripts/check-public-api-bridge.js", "release:check:docker": "node scripts/check-release.js --docker-build", - "validate": "pnpm run db:generate && pnpm run check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:e2e && pnpm run build && pnpm run audit:dependencies", + "validate": "pnpm run db:generate && pnpm run check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:e2e && pnpm run test:production-contract && pnpm run build && pnpm run audit:dependencies", "prepare": "husky" }, "dependencies": {