diff --git a/README.md b/README.md index 278f1c9..d3e82b3 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ Current runtime ownership is intentionally narrow and explicit: loadable even when downstream image layers override `ODOO_ADDONS_PATH`. `/web/health` remains the local container liveness check; Launchplane runtime identity evidence is exposed by the base image at `/launchplane/health`. +- Public single-database runtimes pin both `db_name` and `dbfilter` to the + configured `ODOO_DB_NAME`, and keep database listing disabled. This keeps + normal website requests on the public hostname bound to the tenant database + instead of falling through to Odoo's database selector. - Public runtimes require `ODOO_ADMIN_PASSWORD`, but startup skips admin hardening when the configured `ODOO_ADMIN_LOGIN` is absent in a restored tenant database. This preserves boot for tenant databases that renamed or diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 1af5759..8f1cc33 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -14,6 +14,7 @@ import configparser import json import os +import re import subprocess import sys import time @@ -139,6 +140,10 @@ def _is_public_runtime(settings: StartupSettings) -> bool: return settings.platform_instance.strip().lower() not in LOCAL_INSTANCE_NAMES +def _database_filter_pattern(database_name: str) -> str: + return f"^{re.escape(database_name)}$" + + def _enforce_public_credential_preflight(settings: StartupSettings) -> None: if not _is_public_runtime(settings): return @@ -158,6 +163,8 @@ def _write_runtime_config(settings: StartupSettings) -> None: options = config_parser["options"] options["admin_passwd"] = settings.master_password options["db_name"] = settings.database_name + if _is_public_runtime(settings): + options["dbfilter"] = _database_filter_pattern(settings.database_name) options["db_user"] = settings.database_user options["db_password"] = settings.database_password options["db_host"] = settings.database_host @@ -267,6 +274,8 @@ def _build_odoo_command( f"--db_user={settings.database_user}", f"--db_password={settings.database_password}", ] + if _is_public_runtime(settings): + command.append(f"--db-filter={_database_filter_pattern(settings.database_name)}") if initialize_modules is not None: normalized_modules = ["base", *initialize_modules] diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index b843767..0e2190c 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import configparser import importlib.util import os import sys @@ -149,6 +150,72 @@ def test_public_runtime_accepts_non_default_configured_credentials(self) -> None odoo_startup._enforce_public_credential_preflight(settings) + def test_public_runtime_config_pins_http_database_filter_to_configured_database(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + parser = configparser.ConfigParser(interpolation=None) + + with patch("builtins.open", unittest.mock.mock_open()) as open_mock: + odoo_startup._write_runtime_config(settings) + + written_config = "".join(call.args[0] for call in open_mock().write.call_args_list) + parser.read_string(written_config) + + self.assertEqual(parser["options"]["db_name"], "opw") + self.assertEqual(parser["options"]["dbfilter"], "^opw$") + + def test_local_runtime_config_does_not_pin_http_database_filter(self) -> None: + settings = self._settings(platform_instance="local") + parser = configparser.ConfigParser(interpolation=None) + + with patch("builtins.open", unittest.mock.mock_open()) as open_mock: + odoo_startup._write_runtime_config(settings) + + written_config = "".join(call.args[0] for call in open_mock().write.call_args_list) + parser.read_string(written_config) + + self.assertEqual(parser["options"]["db_name"], "opw") + self.assertNotIn("dbfilter", parser["options"]) + + def test_database_filter_escapes_database_name(self) -> None: + pattern = odoo_startup._database_filter_pattern("tenant.prod") + + self.assertEqual(pattern, r"^tenant\.prod$") + + def test_public_odoo_server_command_pins_database_filter_to_configured_database(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + + command = odoo_startup._build_odoo_command(settings, stop_after_init=False) + + self.assertIn("-d", command) + self.assertIn("opw", command) + self.assertIn("--db-filter=^opw$", command) + + def test_public_odoo_init_command_pins_database_filter_to_configured_database(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + + command = odoo_startup._build_odoo_command( + settings, + initialize_modules=("opw_custom",), + stop_after_init=True, + ) + + self.assertIn("--db-filter=^opw$", command) + self.assertIn("--stop-after-init", command) + + def test_local_odoo_server_command_does_not_pin_database_filter(self) -> None: + settings = self._settings(platform_instance="local") + + command = odoo_startup._build_odoo_command(settings, stop_after_init=False) + + self.assertNotIn("--db-filter=^opw$", command) + + def test_odoo_shell_command_does_not_pin_database_filter(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + + command = odoo_startup._build_odoo_shell_command(settings) + + self.assertFalse(any(argument.startswith("--db-filter=") for argument in command)) + def test_local_runtime_allows_missing_admin_password(self) -> None: settings = self._settings(platform_instance="local", admin_password="")