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
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,13 @@ Current runtime ownership is intentionally narrow and explicit:
Restored databases that undergo sanitization get an active dummy outgoing
server, following Odoo's neutralization behavior, so even configured SMTP
fallback cannot send copied customer mail. Copied SMTP usernames/passwords are
cleared. Fresh bootstrap does not insert that dummy server, so an empty new
database can use the operator's explicitly supplied mail configuration.
cleared. Non-production instances (anything other than `prod`/`production`,
including an empty `PLATFORM_INSTANCE`) get the same dummy server on
bootstrap, every post-deploy maintenance run, and every restore, even with
`--no-sanitize`, so testing and preview lanes cannot send mail whatever SMTP
settings they receive. Production bootstrap does not insert the dummy server,
so a new production database can use the operator's supplied mail
configuration.
- Restores onto a non-production instance also clear the copy's production
integration credentials and signing keys (Shopify, PrintNode, map and media
tokens, web push keys and devices, `database.secret`), and the copy's
Expand Down
39 changes: 32 additions & 7 deletions docker/scripts/run_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -977,15 +977,11 @@ def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None:
sql_calls.append(SqlCall("ir.cron", KeyValuePair("active", False)))

_logger.info("Sanitizing database...")
# An active dummy server also blocks Odoo's config/CLI SMTP fallback.
# Match Odoo's neutralization behavior and remove copied credentials.
with self.connect_to_db().cursor() as cursor:
cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL")
if block_smtp_fallback:
cursor.execute(
"INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) "
"VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')"
)
self._block_outgoing_mail(cursor)
else:
cursor.execute("UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL")
# noinspection PyUnresolvedReferences # call_odoo_sql exists on this class; PyCharm false positive.
call_odoo_sql = self.call_odoo_sql
for sql_call in sql_calls:
Expand All @@ -999,6 +995,31 @@ def sanitize_database(self, *, block_smtp_fallback: bool = True) -> None:
errors = "\n".join(f"- {cron[7]} (id: {cron[0]})" for cron in active_crons)
raise OdooDatabaseUpdateError(f"Error: The following cron jobs are still active after sanitization:\n{errors}")

@staticmethod
def _block_outgoing_mail(cursor: Any) -> None:
# An active dummy server also blocks Odoo's config/CLI SMTP fallback.
# Match Odoo's neutralization behavior and remove copied credentials.
cursor.execute(
"UPDATE ir_mail_server SET active = false, smtp_user = NULL, smtp_pass = NULL "
"WHERE name <> 'neutralization - disable emails'"
)
cursor.execute("UPDATE ir_mail_server SET active = true WHERE name = 'neutralization - disable emails'")
if cursor.rowcount == 0:
cursor.execute(
"INSERT INTO ir_mail_server (name, smtp_port, smtp_host, smtp_encryption, active, smtp_authentication) "
"VALUES ('neutralization - disable emails', 1025, 'invalid', 'none', true, 'login')"
)

def block_outgoing_mail_outside_production(self) -> None:
"""Keep non-production lanes from sending mail, whatever SMTP settings they were given."""
if self._is_production_instance():
return
connection_ = self.connect_to_db()
with connection_.cursor() as cursor:
self._block_outgoing_mail(cursor)
connection_.commit()
_logger.info("Blocked outgoing mail on non-production instance '%s'.", self.local.platform_instance)

def _is_production_instance(self) -> bool:
return self.local.platform_instance.strip().lower() in PRODUCTION_INSTANCE_NAMES

Expand Down Expand Up @@ -1739,6 +1760,7 @@ def run_bootstrap(self, *, do_sanitize: bool) -> None:
self.drop_database()
raise

self.block_outgoing_mail_outside_production()
self.ensure_admin_user()
self.connect_to_db()
self.assert_core_schema_healthy()
Expand All @@ -1753,6 +1775,7 @@ def run_post_deploy_maintenance(self) -> None:
self.reconcile_missing_manifest_install_queue()
self.assert_install_queue_is_resolvable()
self.apply_environment_overrides()
self.block_outgoing_mail_outside_production()
self.ensure_admin_user()
self.connect_to_db()
self.assert_core_schema_healthy()
Expand Down Expand Up @@ -2390,6 +2413,7 @@ def _restore_from_verified_dump(self, backup_path: Path, *, do_sanitize: bool) -
# Clear credentials before any Odoo code (OpenUpgrade, install hooks) runs against the copy.
self.fingerprint_restored_credentials()
self.neutralize_production_credentials()
self.block_outgoing_mail_outside_production()
filestore_waited = True
filestore_returncode = filestore_process.wait()
if filestore_returncode != 0:
Expand Down Expand Up @@ -2442,6 +2466,7 @@ def _prepare_restored_database(self, target_owner: str | None, *, do_sanitize: b
self.neutralize_production_credentials()
self.verify_production_credentials_cleared()
self.apply_environment_overrides()
self.block_outgoing_mail_outside_production()


if __name__ == "__main__": # pragma: no cover
Expand Down
135 changes: 103 additions & 32 deletions tests/test_odoo_data_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,38 +290,54 @@ def test_failed_pg_restore_exits_non_zero_and_does_not_claim_the_target_is_intac


class OdooDataWorkflowShellEnvironmentTests(unittest.TestCase):
def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None:
with closing(sqlite3.connect(":memory:")) as database:
database.execute(
"CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, "
"active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)"
)
runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(), upstream=None, env_file=None)
runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit)
with patch.multiple(
runner,
_resolve_filestore_owner=MagicMock(return_value=None),
database_exists=MagicMock(return_value=False),
_clean_filestore=MagicMock(),
normalize_filestore_permissions=MagicMock(),
create_database=MagicMock(),
_reset_db_connection=MagicMock(),
needs_base_install=MagicMock(return_value=False),
install_addons=MagicMock(),
update_addons=MagicMock(),
call_odoo_sql=MagicMock(return_value=[]),
assert_install_queue_is_resolvable=MagicMock(),
apply_environment_overrides=MagicMock(),
ensure_admin_user=MagicMock(),
assert_core_schema_healthy=MagicMock(),
ensure_gpt_users=MagicMock(),
):
runner.run_bootstrap(do_sanitize=True)
@staticmethod
def _mail_database() -> sqlite3.Connection:
database = sqlite3.connect(":memory:")
database.execute(
"CREATE TABLE ir_mail_server (name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, "
"active BOOLEAN, smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT)"
)
return database

@staticmethod
def _add_production_mail_server(database: sqlite3.Connection) -> None:
database.execute(
"INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', "
"'mailbox@example.test', 'copied-secret')"
)

def _mail_runner(self, database: sqlite3.Connection, platform_instance: str) -> Any:
runner = odoo_data_workflows.OdooDataWorkflowRunner(self._local_settings(platform_instance), upstream=None, env_file=None)
runner.local.db_conn = types.SimpleNamespace(cursor=lambda: closing(database.cursor()), commit=database.commit)
return runner

def _run_bootstrap(self, runner: Any) -> None:
with patch.multiple(
runner,
_resolve_filestore_owner=MagicMock(return_value=None),
database_exists=MagicMock(return_value=False),
_clean_filestore=MagicMock(),
normalize_filestore_permissions=MagicMock(),
create_database=MagicMock(),
_reset_db_connection=MagicMock(),
needs_base_install=MagicMock(return_value=False),
install_addons=MagicMock(),
update_addons=MagicMock(),
call_odoo_sql=MagicMock(return_value=[]),
assert_install_queue_is_resolvable=MagicMock(),
apply_environment_overrides=MagicMock(),
ensure_admin_user=MagicMock(),
assert_core_schema_healthy=MagicMock(),
ensure_gpt_users=MagicMock(),
):
runner.run_bootstrap(do_sanitize=True)

def test_production_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self) -> None:
with closing(self._mail_database()) as database:
runner = self._mail_runner(database, "prod")
self._run_bootstrap(runner)
self.assertEqual(database.execute("SELECT count(*) FROM ir_mail_server WHERE active = true").fetchone()[0], 0)
database.execute(
"INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', "
"'mailbox@example.test', 'copied-secret')"
)
self._add_production_mail_server(database)
with patch.object(runner, "call_odoo_sql", return_value=[]):
runner.sanitize_database()
runner.sanitize_database()
Expand All @@ -336,9 +352,41 @@ def test_bootstrap_allows_configured_mail_but_sanitized_restores_block_it(self)
0,
)

def test_non_production_bootstrap_blocks_configured_mail(self) -> None:
with closing(self._mail_database()) as database:
self._run_bootstrap(self._mail_runner(database, "testing"))
self.assertEqual(
database.execute("SELECT smtp_host, smtp_port FROM ir_mail_server WHERE active = true").fetchall(),
[("invalid", 1025)],
)

def test_non_production_mail_block_is_repeatable_and_clears_credentials(self) -> None:
with closing(self._mail_database()) as database:
self._add_production_mail_server(database)
runner = self._mail_runner(database, "testing")
with patch.object(runner, "connect_to_db", return_value=runner.local.db_conn):
runner.block_outgoing_mail_outside_production()
runner.block_outgoing_mail_outside_production()
self.assertEqual(
database.execute("SELECT name, active, smtp_user, smtp_pass FROM ir_mail_server ORDER BY name").fetchall(),
[("Production", 0, None, None), ("neutralization - disable emails", 1, None, None)],
)

def test_production_mail_is_left_alone(self) -> None:
with closing(self._mail_database()) as database:
self._add_production_mail_server(database)
runner = self._mail_runner(database, "prod")
with patch.object(runner, "connect_to_db", return_value=runner.local.db_conn):
runner.block_outgoing_mail_outside_production()
self.assertEqual(
database.execute("SELECT name, active, smtp_user FROM ir_mail_server").fetchall(),
[("Production", 1, "mailbox@example.test")],
)

@staticmethod
def _local_settings() -> object:
def _local_settings(platform_instance: str = "") -> object:
return odoo_data_workflows.LocalServerSettings(
PLATFORM_INSTANCE=platform_instance,
ODOO_DB_HOST="database",
ODOO_DB_PORT="5432",
ODOO_DB_USER="odoo",
Expand Down Expand Up @@ -382,6 +430,11 @@ def test_post_deploy_maintenance_runs_overrides_and_service_user_provisioning(se
"apply_environment_overrides",
side_effect=lambda: calls.append("apply_environment_overrides"),
),
patch.object(
runner,
"block_outgoing_mail_outside_production",
side_effect=lambda: calls.append("block_outgoing_mail_outside_production"),
),
patch.object(runner, "ensure_admin_user", side_effect=lambda: calls.append("ensure_admin_user")),
patch.object(
runner,
Expand All @@ -402,6 +455,7 @@ def test_post_deploy_maintenance_runs_overrides_and_service_user_provisioning(se
"reconcile_missing_manifest_install_queue",
"assert_install_queue_is_resolvable",
"apply_environment_overrides",
"block_outgoing_mail_outside_production",
"ensure_admin_user",
"connect_to_db",
"assert_core_schema_healthy",
Expand Down Expand Up @@ -886,6 +940,10 @@ def __init__(self) -> None:
CREATE TABLE ir_act_server (id INTEGER PRIMARY KEY, model_id INTEGER, code TEXT);
CREATE TABLE ir_cron (id INTEGER PRIMARY KEY, cron_name TEXT, active BOOLEAN, ir_actions_server_id INTEGER);
CREATE TABLE ir_model_data (id INTEGER PRIMARY KEY, module TEXT, name TEXT, model TEXT, res_id INTEGER);
CREATE TABLE ir_mail_server (
name TEXT, smtp_port INTEGER, smtp_host TEXT, smtp_encryption TEXT, active BOOLEAN,
smtp_authentication TEXT, smtp_user TEXT, smtp_pass TEXT
);
"""
)
self.tables = {row[0] for row in self.database.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
Expand Down Expand Up @@ -929,6 +987,10 @@ def __init__(self) -> None:
[(1, "Shopify Sync - Dispatcher", True, 1), (2, "Mail: send queue", True, 2), (3, "Shopify reconcile", True, 3)],
)
self.database.execute("INSERT INTO ir_model_data VALUES (1, 'shopify_sync', 'ir_cron_shopify_sync_dispatch', 'ir.cron', 1)")
self.database.execute(
"INSERT INTO ir_mail_server VALUES ('Production', 587, 'smtp.example.test', 'starttls', true, 'login', "
"'mailbox@example.test', 'copied-secret')"
)

def cursor(self) -> closing:
return closing(_ParamstyleCursor(self.database.cursor()))
Expand All @@ -945,6 +1007,9 @@ def parameters(self) -> dict[str, str]:
def scalar(self, query: str) -> object:
return self.database.execute(query).fetchone()[0]

def active_mail_servers(self) -> list[tuple]:
return self.database.execute("SELECT smtp_host, smtp_user, smtp_pass FROM ir_mail_server WHERE active = true").fetchall()


class _ParamstyleCursor:
"""Runs the workflow's psycopg2-style (%s) SQL against SQLite."""
Expand All @@ -965,6 +1030,10 @@ def fetchall(self) -> list[tuple]:
def description(self) -> object:
return self._cursor.description

@property
def rowcount(self) -> int:
return self._cursor.rowcount

def close(self) -> None:
self._cursor.close()

Expand Down Expand Up @@ -1168,6 +1237,8 @@ def apply_launchplane_settings() -> None:
self.assertNotIn(key, parameters)
self.assertNotEqual(parameters["database.secret"], PRODUCTION_PARAMETERS["database.secret"])
self.assertEqual(self.copy.scalar("SELECT active FROM ir_cron WHERE id = 1"), 0)
self.assertEqual(self.copy.active_mail_servers(), [("invalid", None, None)])
self.assertEqual(self.copy.scalar("SELECT count(*) FROM ir_mail_server WHERE smtp_pass IS NOT NULL"), 0)

def test_restore_that_fails_after_pg_restore_drops_the_production_copy(self) -> None:
runner = self._runner("testing")
Expand Down
Loading