From 7d7812e46267349f000dbfbaaf35f934a28f36a2 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 18:06:31 -0400 Subject: [PATCH 1/2] Avoid rewriting unchanged administrator passwords at startup --- README.md | 4 ++ docker/scripts/run_odoo_startup.py | 13 ++++- tests/test_odoo_startup.py | 77 ++++++++++++++++++++++++++---- 3 files changed, 83 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ec2382d..b19717e 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,10 @@ Current runtime ownership is intentionally narrow and explicit: tenant database. This preserves boot for tenant databases that renamed or removed the default `admin` login while still checking active default admin passwords when matching users exist. +- Startup verifies an existing administrator password before writing it. A + matching configured password is left unchanged, avoiding password-change + emails on ordinary restarts. Actual configured password changes still use + Odoo's normal write path and security notifications. - A Postgres major-version bump is not a routine dependency refresh on this surface. Treat it as explicit migration work with a documented upgrade path for existing tenant data volumes. diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index cad3cab..89b6bfb 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -434,6 +434,7 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None: } script = """ import json +from odoo.exceptions import AccessDenied payload = json.loads('__PAYLOAD__') admin_user = env['res.users'].sudo().with_context(active_test=False).search( @@ -443,9 +444,17 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None: if not admin_user: print(f"configured_admin_user_found=false login={payload['login']}") else: - admin_user.with_context(no_reset_password=True).sudo().write({'password': payload['password']}) + try: + admin_user.with_user(admin_user)._check_credentials( + {'type': 'password', 'password': payload['password']}, + {'interactive': True}, + ) + except AccessDenied: + admin_user.with_context(no_reset_password=True).sudo().write({'password': payload['password']}) + print('admin_password_updated=true') + else: + print('admin_password_updated=false') env.cr.commit() - print('admin_password_updated=true') """.replace("__PAYLOAD__", json.dumps(payload)) _run_odoo_shell(settings, script, label="admin hardening") diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index 5d39a44..e90119e 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -13,9 +13,10 @@ from pathlib import Path from tempfile import TemporaryDirectory from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import MagicMock, patch if TYPE_CHECKING: + from docker.scripts import run_odoo_startup as odoo_startup from docker.scripts.run_odoo_startup import StartupSettings @@ -41,7 +42,8 @@ def _unexpected_connect(*unused_args: object, **unused_kwargs: object) -> None: return module -odoo_startup = _load_startup_module() +if not TYPE_CHECKING: + odoo_startup = _load_startup_module() class OdooStartupDependencySyncTests(unittest.TestCase): @@ -291,16 +293,73 @@ def test_odoo_shell_subprocess_prepends_runtime_scripts_to_pythonpath(self) -> N environment = run_mock.call_args.kwargs["env"] self.assertEqual(environment["PYTHONPATH"], "/volumes/scripts:/opt/custom") - def test_admin_hardening_skips_missing_configured_admin(self) -> None: - settings = self._settings(platform_instance="testing", admin_password="safe-admin-password") + @staticmethod + def _execute_admin_hardening(settings: StartupSettings, environment: MagicMock) -> str: + exceptions = types.ModuleType("odoo.exceptions") + exceptions.__dict__["AccessDenied"] = PermissionError + + def run_shell(_settings: StartupSettings, script: str, *, label: str) -> None: + _ = label + exec(script, {"env": environment}) - with patch.object(odoo_startup, "_run_odoo_shell") as run_shell: + output = io.StringIO() + with ( + patch.dict(sys.modules, {"odoo.exceptions": exceptions}), + patch.object(odoo_startup, "_run_odoo_shell", side_effect=run_shell), + redirect_stdout(output), + ): odoo_startup._apply_admin_password_if_configured(settings) + return output.getvalue() + + def test_admin_hardening_only_writes_when_configured_password_changes(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="configured-password") + environment = MagicMock() + admin = environment["res.users"].sudo().with_context().search() + admin.with_user.return_value = admin + admin.with_context.return_value = admin + admin.sudo.return_value = admin + stored = {"password": "initial-password"} + + def check_credentials(credential: dict[str, str], _request_environment: dict[str, bool]) -> None: + if credential["password"] != stored["password"]: + raise PermissionError + + admin._check_credentials.side_effect = check_credentials + admin.write.side_effect = stored.update + self._execute_admin_hardening(settings, environment) + self._execute_admin_hardening(settings, environment) + admin.write.assert_called_once_with({"password": "configured-password"}) + + rotated = replace(settings, admin_password="rotated-password") + self._execute_admin_hardening(rotated, environment) + self._execute_admin_hardening(rotated, environment) + self.assertEqual(admin.write.call_count, 2) + self.assertEqual(stored["password"], "rotated-password") + environment.cr.commit.assert_called() + + def test_admin_hardening_skips_missing_configured_admin(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="configured-password") + environment = MagicMock() + users = environment["res.users"].sudo().with_context() + users.search.return_value = None + + output = self._execute_admin_hardening(settings, environment) + + self.assertIn("configured_admin_user_found=false", output) + environment.cr.commit.assert_not_called() + + def test_admin_hardening_does_not_write_after_unexpected_credential_check_failure(self) -> None: + settings = self._settings(platform_instance="testing", admin_password="configured-password") + environment = MagicMock() + admin = environment["res.users"].sudo().with_context().search() + admin.with_user.return_value = admin + admin._check_credentials.side_effect = RuntimeError("credential backend unavailable") + + with self.assertRaisesRegex(RuntimeError, "credential backend unavailable"): + self._execute_admin_hardening(settings, environment) - run_shell.assert_called_once() - script_text = run_shell.call_args.args[1] - self.assertIn("configured_admin_user_found=false", script_text) - self.assertNotIn("Configured admin user not found", script_text) + admin.with_context.assert_not_called() + environment.cr.commit.assert_not_called() if __name__ == "__main__": From 764af095328ec75594896ce7ce8a8a3845195b37 Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Thu, 24 Sep 2026 18:17:23 -0400 Subject: [PATCH 2/2] Preserve quotes and backslashes in startup administrator credentials --- README.md | 3 ++- docker/scripts/run_odoo_startup.py | 4 ++-- tests/test_odoo_startup.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b19717e..7790dc6 100644 --- a/README.md +++ b/README.md @@ -197,7 +197,8 @@ Current runtime ownership is intentionally narrow and explicit: - Startup verifies an existing administrator password before writing it. A matching configured password is left unchanged, avoiding password-change emails on ordinary restarts. Actual configured password changes still use - Odoo's normal write path and security notifications. + Odoo's normal write path and security notifications. Quotes and backslashes + in configured passwords are preserved when passed into the startup shell. - A Postgres major-version bump is not a routine dependency refresh on this surface. Treat it as explicit migration work with a documented upgrade path for existing tenant data volumes. diff --git a/docker/scripts/run_odoo_startup.py b/docker/scripts/run_odoo_startup.py index 89b6bfb..cb7264d 100644 --- a/docker/scripts/run_odoo_startup.py +++ b/docker/scripts/run_odoo_startup.py @@ -436,7 +436,7 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None: import json from odoo.exceptions import AccessDenied -payload = json.loads('__PAYLOAD__') +payload = json.loads(__PAYLOAD__) admin_user = env['res.users'].sudo().with_context(active_test=False).search( [('login', '=', payload['login'])], limit=1, @@ -455,7 +455,7 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None: else: print('admin_password_updated=false') env.cr.commit() -""".replace("__PAYLOAD__", json.dumps(payload)) +""".replace("__PAYLOAD__", repr(json.dumps(payload))) _run_odoo_shell(settings, script, label="admin hardening") diff --git a/tests/test_odoo_startup.py b/tests/test_odoo_startup.py index e90119e..4c8491c 100644 --- a/tests/test_odoo_startup.py +++ b/tests/test_odoo_startup.py @@ -312,7 +312,8 @@ def run_shell(_settings: StartupSettings, script: str, *, label: str) -> None: return output.getvalue() def test_admin_hardening_only_writes_when_configured_password_changes(self) -> None: - settings = self._settings(platform_instance="testing", admin_password="configured-password") + configured_password = "configured-'\"\\-password" + settings = self._settings(platform_instance="testing", admin_password=configured_password) environment = MagicMock() admin = environment["res.users"].sudo().with_context().search() admin.with_user.return_value = admin @@ -328,7 +329,7 @@ def check_credentials(credential: dict[str, str], _request_environment: dict[str admin.write.side_effect = stored.update self._execute_admin_hardening(settings, environment) self._execute_admin_hardening(settings, environment) - admin.write.assert_called_once_with({"password": "configured-password"}) + admin.write.assert_called_once_with({"password": configured_password}) rotated = replace(settings, admin_password="rotated-password") self._execute_admin_hardening(rotated, environment)