diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 8619fc9..b4fe8dd 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -35,6 +35,9 @@ ("ODOO_LIMIT_MEMORY_HARD", "limit_memory_hard"), ) +UNSAFE_MASTER_PASSWORDS = {"admin"} +LOCAL_INSTANCE_NAMES = {"", "local", "dev", "development"} + @dataclass(frozen=True) class StartupSettings: @@ -117,6 +120,19 @@ def _load_settings(argument_namespace: argparse.Namespace) -> StartupSettings: ) +def _is_public_runtime(settings: StartupSettings) -> bool: + return settings.platform_instance.strip().lower() not in LOCAL_INSTANCE_NAMES + + +def _enforce_public_credential_preflight(settings: StartupSettings) -> None: + if not _is_public_runtime(settings): + return + if settings.master_password.strip().lower() in UNSAFE_MASTER_PASSWORDS: + raise RuntimeError("Insecure configuration: ODOO_MASTER_PASSWORD must not use a default value for public runtimes.") + if not settings.admin_password: + raise RuntimeError("Insecure configuration: ODOO_ADMIN_PASSWORD must be set for public runtimes.") + + def _write_runtime_config(settings: StartupSettings) -> None: config_parser = configparser.ConfigParser(interpolation=None) if settings.base_config_path and os.path.exists(settings.base_config_path): @@ -482,6 +498,7 @@ def _wait_for_data_workflow_lock(settings: StartupSettings) -> None: def main() -> None: arguments = _parse_arguments() settings = _load_settings(arguments) + _enforce_public_credential_preflight(settings) _write_runtime_config(settings) _wait_for_database(settings) _wait_for_data_workflow_lock(settings) @@ -489,7 +506,7 @@ def main() -> None: _run_initialization_if_needed(settings) _apply_environment_overrides_if_available(settings) _apply_admin_password_if_configured(settings) - if settings.admin_password: + if settings.admin_password or _is_public_runtime(settings): _assert_active_admin_password_is_not_default(settings) print("[platform-startup] starting Odoo web server", flush=True) diff --git a/docs/tooling/workspace-cli.md b/docs/tooling/workspace-cli.md index 8590677..7512636 100644 --- a/docs/tooling/workspace-cli.md +++ b/docs/tooling/workspace-cli.md @@ -207,6 +207,11 @@ Notes - Native non-local ownership currently covers Dokploy-backed `restore`, `workflow bootstrap`, and `workflow update`; anything else should fail closed unless `odoo-devkit` grows an explicit remote contract for it. +- Public and non-local Odoo runtimes fail closed on unsafe startup credentials: + the master password must be present and non-default, and an explicit admin + password must be configured before the startup wrapper marks the runtime + usable. Local developer runtimes may omit the admin password, but previews, + testing, and prod must not expose an Odoo database with default credentials. - Release/deploy ownership for remote environments stays in `launchplane`, even when the same tenant manifest is used to anchor local runtime context. diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 9c81668..2b89ecd 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -36,6 +36,35 @@ def _unexpected_connect(*unused_args: object, **unused_kwargs: object) -> None: class OdooStartupDependencySyncTests(unittest.TestCase): + @staticmethod + def _settings( + *, + platform_instance: str = "local", + master_password: str = "master-password", + admin_password: str = "", + ) -> object: + return odoo_startup.StartupSettings( + config_path="/tmp/generated.conf", + base_config_path="/tmp/base.conf", + platform_instance=platform_instance, + database_name="opw", + database_host="database", + database_port=5432, + database_user="odoo", + database_password="database-password", + master_password=master_password, + admin_login="admin", + admin_password=admin_password, + addons_path="/odoo/addons", + data_dir="/volumes/data", + list_db="False", + install_modules=("opw_custom",), + data_workflow_lock_file="/volumes/data/.data_workflow_in_progress", + data_workflow_lock_timeout_seconds=7200, + ready_timeout_seconds=180, + poll_interval_seconds=2.0, + ) + def test_load_settings_reads_platform_instance(self) -> None: environment = { "PLATFORM_INSTANCE": "local", @@ -55,27 +84,7 @@ def test_load_settings_reads_platform_instance(self) -> None: @staticmethod def test_sync_python_dependencies_runs_for_local_dev_runtime() -> None: - settings = odoo_startup.StartupSettings( - config_path="/tmp/generated.conf", - base_config_path="/tmp/base.conf", - platform_instance="local", - database_name="opw", - database_host="database", - database_port=5432, - database_user="odoo", - database_password="database-password", - master_password="master-password", - admin_login="admin", - admin_password="", - addons_path="/odoo/addons", - data_dir="/volumes/data", - list_db="False", - install_modules=("opw_custom",), - data_workflow_lock_file="/volumes/data/.data_workflow_in_progress", - data_workflow_lock_timeout_seconds=7200, - ready_timeout_seconds=180, - poll_interval_seconds=2.0, - ) + settings = OdooStartupDependencySyncTests._settings(platform_instance="local") with ( patch.dict(os.environ, {"ODOO_DEV_MODE": "reload"}, clear=True), @@ -87,33 +96,43 @@ def test_sync_python_dependencies_runs_for_local_dev_runtime() -> None: @staticmethod def test_sync_python_dependencies_skips_non_local_runtime() -> None: - settings = odoo_startup.StartupSettings( - config_path="/tmp/generated.conf", - base_config_path="/tmp/base.conf", - platform_instance="prod", - database_name="opw", - database_host="database", - database_port=5432, - database_user="odoo", - database_password="database-password", - master_password="master-password", - admin_login="admin", - admin_password="", - addons_path="/odoo/addons", - data_dir="/volumes/data", - list_db="False", - install_modules=("opw_custom",), - data_workflow_lock_file="/volumes/data/.data_workflow_in_progress", - data_workflow_lock_timeout_seconds=7200, - ready_timeout_seconds=180, - poll_interval_seconds=2.0, - ) + settings = OdooStartupDependencySyncTests._settings(platform_instance="prod") with patch.object(odoo_startup, "_install_local_addon_dependencies") as mocked_install_dependencies: odoo_startup._sync_python_dependencies_if_needed(settings) mocked_install_dependencies.assert_not_called() + def test_public_runtime_rejects_default_master_password(self) -> None: + settings = self._settings( + platform_instance="preview", + master_password="admin", + admin_password="safe-admin-password", + ) + + with self.assertRaisesRegex(RuntimeError, "ODOO_MASTER_PASSWORD"): + odoo_startup._enforce_public_credential_preflight(settings) + + def test_public_runtime_requires_configured_admin_password(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="") + + with self.assertRaisesRegex(RuntimeError, "ODOO_ADMIN_PASSWORD"): + odoo_startup._enforce_public_credential_preflight(settings) + + def test_public_runtime_accepts_non_default_configured_credentials(self) -> None: + settings = self._settings( + platform_instance="prod", + master_password="master-password", + admin_password="safe-admin-password", + ) + + odoo_startup._enforce_public_credential_preflight(settings) + + def test_local_runtime_allows_missing_admin_password(self) -> None: + settings = self._settings(platform_instance="local", admin_password="") + + odoo_startup._enforce_public_credential_preflight(settings) + if __name__ == "__main__": unittest.main()