Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ 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. 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.
Expand Down
17 changes: 13 additions & 4 deletions docker/scripts/run_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,19 +434,28 @@ def _apply_admin_password_if_configured(settings: StartupSettings) -> None:
}
script = """
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,
)
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))
""".replace("__PAYLOAD__", repr(json.dumps(payload)))
_run_odoo_shell(settings, script, label="admin hardening")


Expand Down
78 changes: 69 additions & 9 deletions tests/test_odoo_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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):
Expand Down Expand Up @@ -291,16 +293,74 @@ 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:
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
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__":
Expand Down
Loading