Перенесены секреты из .env в SM
This commit is contained in:
@@ -44,11 +44,13 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("networks: [backend, observability, egress]", application)
|
||||
self.assertIn("networks: [public, backend, observability, egress]", application)
|
||||
self.assertIn('KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY: ""', application)
|
||||
self.assertIn(
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY_FILE: "
|
||||
"/run/secrets/keycloak_yandex_captcha_server_key",
|
||||
application,
|
||||
)
|
||||
self.assertEqual(
|
||||
application.count(
|
||||
"IDGTL_SMS_API_KEY: ${IDGTL_SMS_API_KEY:?IDGTL_SMS_API_KEY is required}"
|
||||
),
|
||||
application.count("IDGTL_SMS_API_KEY_FILE: /run/secrets/idgtl_sms_api_key"),
|
||||
1,
|
||||
)
|
||||
|
||||
@@ -56,7 +58,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertEqual(jobs.count("networks: [backend, egress]"), 5)
|
||||
|
||||
observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertIn("networks: [observability, backend, egress]", observability)
|
||||
self.assertIn("networks: [observability, egress]", observability)
|
||||
|
||||
redis = (ROOT / "redis/docker-compose.yml").read_text(encoding="utf-8")
|
||||
self.assertNotIn("egress", redis)
|
||||
@@ -64,6 +66,8 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
def test_only_nginx_fragment_publishes_ports(self) -> None:
|
||||
forbidden = (
|
||||
ROOT / "infra/compose/application.yml",
|
||||
ROOT / "api-backend/docker-compose.yml",
|
||||
ROOT / "keycloak/docker-compose.yml",
|
||||
ROOT / "redis/docker-compose.yml",
|
||||
ROOT / "observability/docker-compose.yml",
|
||||
ROOT / "deployment/docker-compose.jobs.yml",
|
||||
@@ -177,6 +181,10 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertIn("storage: file_storage", config)
|
||||
self.assertIn("retry_on_failure:", config)
|
||||
self.assertIn('insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"', config)
|
||||
self.assertIn(
|
||||
"authorization: ${file:/run/secrets/otel_remote_auth_header}", config
|
||||
)
|
||||
self.assertNotIn("OTEL_REMOTE_AUTH_HEADER", config)
|
||||
self.assertIn("tail_sampling:", config)
|
||||
self.assertNotIn("probabilistic_sampler:", config)
|
||||
self.assertIn('targets: ["sms-service:8080"]', config)
|
||||
@@ -231,7 +239,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
|
||||
def test_smoke_script_does_not_source_env_as_shell(self) -> None:
|
||||
smoke = (ROOT / "deployment/scripts/smoke.sh").read_text(encoding="utf-8")
|
||||
self.assertNotIn('. "./$ENV_FILE"', smoke)
|
||||
self.assertNotIn('. "./$CONFIG_FILE"', smoke)
|
||||
for variable in ("PUBLIC_HOST", "PUBLIC_WEB_URL", "KEYCLOAK_REALM"):
|
||||
self.assertIn(f"{variable}=$(env_value {variable})", smoke)
|
||||
|
||||
@@ -298,18 +306,64 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
for variable in (
|
||||
"KC_DB_SCHEMA",
|
||||
"KEYCLOAK_OTP_MOCK_ENABLED",
|
||||
"KEYCLOAK_OTP_MOCK_CODE",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
"KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_URL",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"KEYCLOAK_SMS_SERVICE_URL",
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN",
|
||||
):
|
||||
self.assertIn(f" {variable}:", application)
|
||||
for variable in (
|
||||
"KEYCLOAK_OTP_MOCK_CODE",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY",
|
||||
"KEYCLOAK_OTP_HMAC_KEY",
|
||||
"KEYCLOAK_SETTINGS_BRIDGE_TOKEN",
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN",
|
||||
):
|
||||
self.assertIn(f" {variable}_FILE:", application)
|
||||
self.assertIn(
|
||||
" KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY: "
|
||||
"${KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY:-}",
|
||||
application,
|
||||
)
|
||||
|
||||
def test_compose_secrets_do_not_enter_config_environment(self) -> None:
|
||||
compose_paths = (
|
||||
ROOT / "infra/compose/application.yml",
|
||||
ROOT / "redis/docker-compose.yml",
|
||||
ROOT / "observability/docker-compose.yml",
|
||||
ROOT / "deployment/docker-compose.jobs.yml",
|
||||
)
|
||||
combined = "\n".join(path.read_text(encoding="utf-8") for path in compose_paths)
|
||||
self.assertNotIn("env_file:", combined)
|
||||
for path in compose_paths:
|
||||
self.assertIn(
|
||||
"core: {soft: 0, hard: 0}",
|
||||
path.read_text(encoding="utf-8"),
|
||||
path,
|
||||
)
|
||||
for variable in (
|
||||
"DATABASE_URL",
|
||||
"REDIS_URL",
|
||||
"SMS_SERVICE_TOKEN",
|
||||
"KEYCLOAK_DB_PASSWORD",
|
||||
"OTEL_REMOTE_AUTH_HEADER",
|
||||
):
|
||||
self.assertNotIn(f"${{{variable}", combined)
|
||||
self.assertIn(
|
||||
"${HAN_SECRETS_DIR:-/run/han-chat/secrets}/DATABASE_URL", combined
|
||||
)
|
||||
self.assertNotIn("entrypoint: []", combined)
|
||||
|
||||
for service in (
|
||||
"api-backend",
|
||||
"sms-service",
|
||||
"message-safety",
|
||||
"bitrix-local-app",
|
||||
"bitrix-sync",
|
||||
"keycloak",
|
||||
):
|
||||
dockerfile = (ROOT / service / "Dockerfile").read_text(encoding="utf-8")
|
||||
self.assertIn("han-container-entrypoint", dockerfile, service)
|
||||
|
||||
def test_env_validator_accepts_materialized_example(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
@@ -317,15 +371,10 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
self.assertNotIn("currentSchema=", example)
|
||||
self.assertIn("KEYCLOAK_DB_SCHEMA=keycloak", example)
|
||||
self.assertIn("HAN_PG_PORT=5433", example)
|
||||
for required in (
|
||||
"CURSOR_HMAC_SECRET=",
|
||||
"BITRIX_TOKEN_ENCRYPTION_KEY=",
|
||||
"KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth",
|
||||
"KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080",
|
||||
"IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms",
|
||||
):
|
||||
self.assertIn(required, example)
|
||||
materialized = example.replace("change-me", "0123456789abcdef0123456789abcdef")
|
||||
self.assertIn("SECRETS_SOURCE=file", example)
|
||||
self.assertNotIn("CURSOR_HMAC_SECRET=", example)
|
||||
self.assertNotIn("BITRIX_TOKEN_ENCRYPTION_KEY=", example)
|
||||
materialized = example
|
||||
materialized = materialized.replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
@@ -341,11 +390,9 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_env_validator_requires_captcha_keys_only_when_enabled(self) -> None:
|
||||
def test_env_validator_keeps_captcha_server_key_out_of_config(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
materialized = example.replace(
|
||||
"change-me", "0123456789abcdef0123456789abcdef"
|
||||
).replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
)
|
||||
@@ -353,33 +400,17 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED=false",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true",
|
||||
)
|
||||
enabled_with_keys = enabled_without_keys.replace(
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY=client-key",
|
||||
).replace(
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=",
|
||||
"KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=server-key-0123456789",
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
env_file = Path(directory) / ".env"
|
||||
env_file.write_text(enabled_without_keys, encoding="utf-8")
|
||||
missing = subprocess.run(
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
env_file.write_text(enabled_with_keys, encoding="utf-8")
|
||||
configured = subprocess.run(
|
||||
[sys.executable, str(ROOT / "scripts/validate-env"), str(env_file)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
self.assertNotEqual(missing.returncode, 0)
|
||||
self.assertIn("KEYCLOAK_YANDEX_CAPTCHA_CLIENT_KEY", missing.stderr)
|
||||
self.assertIn("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY", missing.stderr)
|
||||
self.assertEqual(configured.returncode, 0, configured.stderr)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("KEYCLOAK_YANDEX_CAPTCHA_SERVER_KEY=", example)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
VALIDATOR = ROOT / "scripts/validate-env"
|
||||
|
||||
|
||||
def runtime_values() -> dict[str, str]:
|
||||
symbols = runpy.run_path(str(VALIDATOR))
|
||||
values = {key: "a" * 32 for key in symbols["REQUIRED_RUNTIME"]}
|
||||
pg_tail = "?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem"
|
||||
values.update(
|
||||
{
|
||||
"DATABASE_URL": f"postgresql+asyncpg://han_app:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_DATABASE_URL": f"postgresql://bitrix:password@pg:5433/han_chat{pg_tail}",
|
||||
"BITRIX_SYNC_DATABASE_URL": f"postgresql://sync:password@pg:5433/han_chat{pg_tail}",
|
||||
"MESSAGE_SAFETY_DATABASE_URL": f"postgresql://safety:password@pg:5433/han_chat{pg_tail}",
|
||||
"SMS_DATABASE_URL": f"postgresql+asyncpg://sms:password@pg:5433/han_chat{pg_tail}",
|
||||
"KEYCLOAK_DB_URL": f"jdbc:postgresql://pg:5433/han_chat{pg_tail}",
|
||||
"REDIS_API_PASSWORD": "r" * 32,
|
||||
"REDIS_SAFETY_PASSWORD": "s" * 32,
|
||||
"REDIS_HEALTH_PASSWORD": "h" * 32,
|
||||
"REDIS_URL": f"redis://api_backend:{'r' * 32}@redis:6379/0",
|
||||
"REDIS_REALTIME_URL": f"redis://api_backend:{'r' * 32}@redis:6379/1",
|
||||
"MESSAGE_SAFETY_REDIS_URL": f"redis://message_safety:{'s' * 32}@redis:6379/2",
|
||||
"BITRIX_LOCAL_APP_INTERNAL_TOKEN": "b" * 32,
|
||||
"BITRIX_INTERNAL_API_TOKEN": "b" * 32,
|
||||
"BITRIX_API_FORWARD_TOKEN": "f" * 32,
|
||||
"BITRIX_API_INBOX_TOKEN": "f" * 32,
|
||||
"KEYCLOAK_SMS_SERVICE_TOKEN": "k" * 32,
|
||||
"SMS_SERVICE_TOKEN": "k" * 32,
|
||||
"KEYCLOAK_OTP_MOCK_CODE": "846271",
|
||||
}
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
class SecretHygieneTests(unittest.TestCase):
|
||||
def run_validator(
|
||||
self, config: Path, *args: str, environment: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(VALIDATOR), str(config), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
def test_example_is_non_secret_config(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
symbols = runpy.run_path(str(VALIDATOR))
|
||||
for key in symbols["FORBIDDEN_CONFIG_KEYS"]:
|
||||
self.assertNotRegex(example, rf"(?m)^{key}=")
|
||||
self.assertIn("SECRETS_SOURCE=file", example)
|
||||
|
||||
def test_config_rejects_secret_key_and_credential_url(self) -> None:
|
||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / ".env"
|
||||
config.write_text(
|
||||
example
|
||||
+ "\nSMS_SERVICE_TOKEN=not-for-config\n"
|
||||
+ "EXTERNAL_URL=https://user:password@example.net/path\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_validator(config)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("SMS_SERVICE_TOKEN", result.stderr)
|
||||
self.assertIn("URL с credentials", result.stderr)
|
||||
self.assertNotIn("not-for-config", result.stderr)
|
||||
|
||||
def test_runtime_environment_is_validated_without_value_disclosure(self) -> None:
|
||||
values = runtime_values()
|
||||
secret_canary = "canary-never-print-" + "x" * 20
|
||||
values["CURSOR_HMAC_SECRET"] = secret_canary
|
||||
environment = {**os.environ, **values}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / ".env"
|
||||
config.write_text(
|
||||
(ROOT / ".env.example").read_text(encoding="utf-8").replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_validator(config, "--runtime-env", environment=environment)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(secret_canary, result.stdout + result.stderr)
|
||||
|
||||
def test_runtime_manifest_reads_protected_files_without_value_disclosure(self) -> None:
|
||||
values = runtime_values()
|
||||
secret_canary = "manifest-canary-" + "z" * 20
|
||||
values["CURSOR_HMAC_SECRET"] = secret_canary
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = root / ".env"
|
||||
config.write_text(
|
||||
(ROOT / ".env.example").read_text(encoding="utf-8").replace(
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
entries = []
|
||||
for index, (key, value) in enumerate(sorted(values.items())):
|
||||
path = root / f"secret-{index}"
|
||||
path.write_text(value + "\n", encoding="utf-8")
|
||||
path.chmod(0o600)
|
||||
entries.append(f"{key}={path.resolve()}")
|
||||
manifest = root / "manifest"
|
||||
manifest.write_text("\n".join(entries) + "\n", encoding="utf-8")
|
||||
result = self.run_validator(config, "--runtime-manifest", str(manifest))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn(secret_canary, result.stdout + result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,471 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LOADER_PATH = ROOT / "deployment" / "secrets" / "secrets_loader.py"
|
||||
LAUNCHER_PATH = ROOT / "deployment" / "secrets" / "han-secrets"
|
||||
SPEC = importlib.util.spec_from_file_location("han_secrets_loader", LOADER_PATH)
|
||||
assert SPEC and SPEC.loader
|
||||
loader = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = loader
|
||||
SPEC.loader.exec_module(loader)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(
|
||||
self, status: int, body: bytes, headers: dict[str, str] | None = None
|
||||
) -> None:
|
||||
self.status = status
|
||||
self._body = body
|
||||
self.headers = headers or {}
|
||||
|
||||
def __enter__(self) -> "FakeResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
return None
|
||||
|
||||
def read(self, amount: int) -> bytes:
|
||||
return self._body[:amount]
|
||||
|
||||
|
||||
class FakeOpener:
|
||||
def __init__(self, outcomes: list[Any]) -> None:
|
||||
self.outcomes = outcomes
|
||||
self.requests: list[Any] = []
|
||||
|
||||
def open(self, request: Any, timeout: float) -> FakeResponse:
|
||||
self.requests.append((request, timeout))
|
||||
outcome = self.outcomes.pop(0)
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
|
||||
|
||||
def identity_response(*, project_scoped: bool = True) -> FakeResponse:
|
||||
token: dict[str, Any] = {
|
||||
"catalog": [
|
||||
{
|
||||
"type": "secrets-manager",
|
||||
"endpoints": [
|
||||
{
|
||||
"region": "ru-test",
|
||||
"interface": "public",
|
||||
"url": "https://secrets.example",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
if project_scoped:
|
||||
token["project"] = {"id": "project-id"}
|
||||
return FakeResponse(
|
||||
201,
|
||||
json.dumps({"token": token}).encode(),
|
||||
{"X-Subject-Token": "iam-token-do-not-log"},
|
||||
)
|
||||
|
||||
|
||||
def secret_response(value: bytes) -> FakeResponse:
|
||||
return FakeResponse(
|
||||
200, json.dumps({"value": base64.b64encode(value).decode()}).encode()
|
||||
)
|
||||
|
||||
|
||||
def selectel_config(runtime_dir: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"mode": "selectel",
|
||||
"runtime_dir": str(runtime_dir),
|
||||
"http": {"timeout_seconds": 2, "retries": 1, "max_response_bytes": 4096},
|
||||
"selectel": {
|
||||
"account_id": "123456",
|
||||
"username": "reader",
|
||||
"project_name": "production",
|
||||
"region": "ru-test",
|
||||
"password_file": "selectel-password",
|
||||
},
|
||||
"secrets": {
|
||||
"DATABASE_URL": {
|
||||
"remote": "han/database-url",
|
||||
"consumers": ["api", "migration"],
|
||||
"max_bytes": 512,
|
||||
},
|
||||
"SHARED_TOKEN": {
|
||||
"remote": "han/shared-token",
|
||||
"consumers": ["api"],
|
||||
"max_bytes": 128,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def make_private(path: Path) -> None:
|
||||
os.chmod(path, 0o600)
|
||||
|
||||
|
||||
class SecretsLoaderTests(unittest.TestCase):
|
||||
def test_decodes_current_secret_from_nested_selectel_version(self) -> None:
|
||||
encoded = base64.b64encode(b"current-secret").decode()
|
||||
|
||||
value = loader.decode_secret(
|
||||
{"name": "DATABASE_URL", "version": {"version_id": 1, "value": encoded}},
|
||||
"DATABASE_URL",
|
||||
1024,
|
||||
)
|
||||
|
||||
self.assertEqual(value, b"current-secret")
|
||||
|
||||
def test_empty_literal_never_requires_provider_or_fallback_value(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
fallback = root / "fallback.env"
|
||||
fallback.write_text("REQUIRED_TOKEN=required-value\n", encoding="utf-8")
|
||||
make_private(fallback)
|
||||
config = {
|
||||
"version": 1,
|
||||
"mode": "file",
|
||||
"runtime_dir": str(root / "run"),
|
||||
"file": {"path": str(fallback)},
|
||||
"secrets": {
|
||||
"REQUIRED_TOKEN": {
|
||||
"remote": "provider-token",
|
||||
"consumers": ["service"],
|
||||
},
|
||||
"OPTIONAL_HEADER": {
|
||||
"literal": "",
|
||||
"consumers": ["service"],
|
||||
},
|
||||
},
|
||||
}
|
||||
config_path = root / "file.json"
|
||||
write_json(config_path, config)
|
||||
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
self.assertEqual((root / "run" / "OPTIONAL_HEADER").read_bytes(), b"")
|
||||
self.assertIn(
|
||||
'OPTIONAL_HEADER=""',
|
||||
(root / "run" / "service.env").read_text(encoding="utf-8"),
|
||||
)
|
||||
|
||||
def test_launcher_uses_public_source_switch_without_exporting_values(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
runtime = root / "runtime"
|
||||
fallback = root / "fallback.env"
|
||||
fallback.write_text("TEST_SECRET=canary-secret-value\n", encoding="utf-8")
|
||||
make_private(fallback)
|
||||
public = root / ".env"
|
||||
public.write_text(
|
||||
"SECRETS_SOURCE=file\nAPP_ENV=production\n", encoding="utf-8"
|
||||
)
|
||||
loader_config = root / "production.file.json"
|
||||
write_json(
|
||||
loader_config,
|
||||
{
|
||||
"version": 1,
|
||||
"mode": "file",
|
||||
"runtime_dir": str(runtime),
|
||||
"file": {"path": str(fallback)},
|
||||
"secrets": {
|
||||
"TEST_SECRET": {
|
||||
"remote": "unused-in-file-mode",
|
||||
"consumers": ["test-service"],
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
probe = (
|
||||
"import json,os,pathlib;"
|
||||
"p=os.environ['TEST_SECRET_FILE'];"
|
||||
"print(json.dumps({'raw':os.environ.get('TEST_SECRET'),"
|
||||
"'value':pathlib.Path(p).read_text()}))"
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(LAUNCHER_PATH),
|
||||
"run",
|
||||
"--config",
|
||||
str(public),
|
||||
"--loader-config",
|
||||
str(loader_config),
|
||||
"--",
|
||||
sys.executable,
|
||||
"-c",
|
||||
probe,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertIsNone(payload["raw"])
|
||||
self.assertEqual(payload["value"], "canary-secret-value")
|
||||
self.assertEqual(
|
||||
(runtime / "manifest").read_text(encoding="utf-8").strip(),
|
||||
f"TEST_SECRET={(runtime / 'TEST_SECRET').resolve()}",
|
||||
)
|
||||
|
||||
def test_selectel_flow_uses_project_scope_catalog_and_per_service_files(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
(credentials / "selectel-password").write_text(
|
||||
"service-user-password\n", encoding="utf-8"
|
||||
)
|
||||
make_private(credentials / "selectel-password")
|
||||
runtime = root / "run"
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, selectel_config(runtime))
|
||||
opener = FakeOpener(
|
||||
[
|
||||
identity_response(),
|
||||
secret_response(b"postgresql://user:password@db/app"),
|
||||
secret_response(b'token with spaces and "quotes"'),
|
||||
]
|
||||
)
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(
|
||||
**kwargs, opener=opener, sleeper=lambda _: None, jitter=lambda: 0
|
||||
)
|
||||
|
||||
consumers = loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
|
||||
self.assertEqual(consumers, ["api", "migration"])
|
||||
auth_request = opener.requests[0][0]
|
||||
auth_payload = json.loads(auth_request.data)
|
||||
self.assertEqual(
|
||||
auth_payload["auth"]["scope"]["project"]["name"], "production"
|
||||
)
|
||||
self.assertEqual(
|
||||
auth_payload["auth"]["identity"]["password"]["user"]["domain"]["name"],
|
||||
"123456",
|
||||
)
|
||||
self.assertEqual(
|
||||
opener.requests[1][0].get_header("X-auth-token"),
|
||||
"iam-token-do-not-log",
|
||||
)
|
||||
self.assertEqual(
|
||||
opener.requests[1][0].full_url,
|
||||
"https://secrets.example/v1/han%2Fdatabase-url",
|
||||
)
|
||||
api_text = (runtime / "api.env").read_text(encoding="utf-8")
|
||||
migration_text = (runtime / "migration.env").read_text(encoding="utf-8")
|
||||
self.assertIn('DATABASE_URL="postgresql://user:password@db/app"', api_text)
|
||||
self.assertIn(
|
||||
'SHARED_TOKEN="token with spaces and \\"quotes\\""', api_text
|
||||
)
|
||||
self.assertNotIn("SHARED_TOKEN", migration_text)
|
||||
if os.name != "nt":
|
||||
self.assertEqual(stat.S_IMODE((runtime / "api.env").stat().st_mode), 0o600)
|
||||
self.assertEqual(stat.S_IMODE(runtime.stat().st_mode), 0o700)
|
||||
|
||||
def test_retry_uses_jitter_and_then_succeeds(self) -> None:
|
||||
opener = FakeOpener(
|
||||
[urllib.error.URLError("temporary"), FakeResponse(200, b"{}")]
|
||||
)
|
||||
sleeps: list[float] = []
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=1,
|
||||
max_response_bytes=1024,
|
||||
opener=opener,
|
||||
sleeper=sleeps.append,
|
||||
jitter=lambda: 0.25,
|
||||
)
|
||||
result = client.request(
|
||||
"GET", "https://provider.example/v1/key", expected=frozenset({200})
|
||||
)
|
||||
self.assertEqual(result.status, 200)
|
||||
self.assertEqual(sleeps, [0.1875])
|
||||
|
||||
def test_paired_canonical_names_fetch_provider_version_once(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
credential = credentials / "selectel-password"
|
||||
credential.write_text("password", encoding="utf-8")
|
||||
make_private(credential)
|
||||
config = selectel_config(root / "run")
|
||||
config["secrets"]["SHARED_TOKEN"]["remote"] = "han/database-url"
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
opener = FakeOpener([identity_response(), secret_response(b"same-value")])
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(**kwargs, opener=opener)
|
||||
|
||||
loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
self.assertEqual(len(opener.requests), 2)
|
||||
output = (root / "run" / "api.env").read_text(encoding="utf-8")
|
||||
self.assertIn('DATABASE_URL="same-value"', output)
|
||||
self.assertIn('SHARED_TOKEN="same-value"', output)
|
||||
|
||||
def test_http_error_is_redacted_and_body_is_not_read(self) -> None:
|
||||
leaked = b"postgresql://admin:secret@db/app iam-token response-body"
|
||||
|
||||
class ExplodingBody(io.BytesIO):
|
||||
def read(self, *args: Any, **kwargs: Any) -> bytes:
|
||||
raise AssertionError("HTTP error body must not be read")
|
||||
|
||||
error = urllib.error.HTTPError(
|
||||
"https://provider.example/v1/key",
|
||||
403,
|
||||
"body contains a secret",
|
||||
{},
|
||||
ExplodingBody(leaked),
|
||||
)
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=0,
|
||||
max_response_bytes=1024,
|
||||
opener=FakeOpener([error]),
|
||||
)
|
||||
with self.assertRaises(loader.LoaderError) as caught:
|
||||
client.request(
|
||||
"GET",
|
||||
"https://provider.example/v1/key",
|
||||
headers={"X-Auth-Token": "iam-token"},
|
||||
expected=frozenset({200}),
|
||||
)
|
||||
message = str(caught.exception)
|
||||
self.assertEqual(message, "provider request failed with HTTP 403")
|
||||
for forbidden in ("secret", "iam-token", "response-body", "postgresql://"):
|
||||
self.assertNotIn(forbidden, message)
|
||||
|
||||
def test_unscoped_token_fails_without_replacing_existing_output(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
credentials = root / "credentials"
|
||||
credentials.mkdir()
|
||||
(credentials / "selectel-password").write_text("password", encoding="utf-8")
|
||||
make_private(credentials / "selectel-password")
|
||||
runtime = root / "run"
|
||||
runtime.mkdir()
|
||||
existing = runtime / "api.env"
|
||||
existing.write_text('DATABASE_URL="old-value"\n', encoding="utf-8")
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, selectel_config(runtime))
|
||||
opener = FakeOpener([identity_response(project_scoped=False)])
|
||||
|
||||
def factory(**kwargs: Any) -> Any:
|
||||
return loader.HTTPClient(**kwargs, opener=opener)
|
||||
|
||||
with self.assertRaisesRegex(loader.LoaderError, "not project-scoped"):
|
||||
loader.run(
|
||||
config_path,
|
||||
environ={"CREDENTIALS_DIRECTORY": str(credentials)},
|
||||
client_factory=factory,
|
||||
)
|
||||
self.assertEqual(
|
||||
existing.read_text(encoding="utf-8"),
|
||||
'DATABASE_URL="old-value"\n',
|
||||
)
|
||||
|
||||
def test_invalid_base64_and_size_limit_fail_closed(self) -> None:
|
||||
with self.assertRaisesRegex(loader.LoaderError, "invalid base64"):
|
||||
loader.decode_secret({"value": "not-base64!"}, "TOKEN", 128)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "configured limit"):
|
||||
loader.decode_secret(
|
||||
{"value": base64.b64encode(b"too-long").decode()}, "TOKEN", 3
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "dotenv"):
|
||||
loader.decode_secret(
|
||||
{"value": base64.b64encode(b"line1\nline2").decode()}, "TOKEN", 128
|
||||
)
|
||||
|
||||
def test_file_mode_is_explicit_strict_and_narrow(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source = root / "fallback.env"
|
||||
source.write_text(
|
||||
"# exact recovery set\n"
|
||||
'DATABASE_URL="postgresql://user:pass@db/app"\n'
|
||||
"SHARED_TOKEN='literal value'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
make_private(source)
|
||||
runtime = root / "run"
|
||||
config = selectel_config(runtime)
|
||||
config["mode"] = "file"
|
||||
config.pop("selectel")
|
||||
config.pop("http")
|
||||
config["file"] = {"path": str(source)}
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
loader.run(config_path, environ={})
|
||||
output = (runtime / "api.env").read_text(encoding="utf-8")
|
||||
self.assertIn("literal value", output)
|
||||
self.assertNotIn("selectel", output)
|
||||
|
||||
source.write_text(
|
||||
"DATABASE_URL=ok\nSHARED_TOKEN=ok\nUNDECLARED=leak\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "undeclared key"):
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
def test_selectel_failure_never_falls_back_to_file_section(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
config = selectel_config(root / "run")
|
||||
config["file"] = {"path": str(root / "fallback.env")}
|
||||
config_path = root / "config.json"
|
||||
write_json(config_path, config)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "forbidden"):
|
||||
loader.run(config_path, environ={})
|
||||
|
||||
def test_relative_credential_requires_systemd_directory(self) -> None:
|
||||
selectel = {"password_file": "credential"}
|
||||
with self.assertRaisesRegex(loader.LoaderError, "CREDENTIALS_DIRECTORY"):
|
||||
loader.credential_value(selectel, {})
|
||||
|
||||
def test_response_content_length_limit_is_enforced(self) -> None:
|
||||
client = loader.HTTPClient(
|
||||
timeout=1,
|
||||
retries=0,
|
||||
max_response_bytes=10,
|
||||
opener=FakeOpener(
|
||||
[FakeResponse(200, b"{}", {"Content-Length": "100"})]
|
||||
),
|
||||
)
|
||||
with self.assertRaisesRegex(loader.LoaderError, "exceeds"):
|
||||
client.request(
|
||||
"GET", "https://provider.example/v1/key", expected=frozenset({200})
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user