From a7cf44249d412d6256dd1dac7bab450c92dfee5a Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 21:11:57 -0400 Subject: [PATCH] feat(fusion_login_audit): hook unknown-user failures via _login Overrides res.users._login. When the login string does not resolve to any user, super() raises AccessDenied; we record a row with user_id=NULL and failure_reason="unknown_user", then re-raise. Closes the gap where typo'd or scanned logins would otherwise vanish from the audit trail. The existing _fc_record_login_event helper writes through an independent registry.cursor(), so the audit row survives the rollback that follows the re-raised AccessDenied. Note: in Odoo 19 _login is a plain instance method (not the classmethod it was in earlier versions) and takes (credential, user_agent_env). The original plan was written for the classmethod signature; corrected here and recorded in CLAUDE.md rule #10 so future-Claude does not waste time re-discovering it. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + fusion_login_audit/models/res_users.py | 28 ++++++++++++++++++ fusion_login_audit/tests/test_login_audit.py | 30 ++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 94ac87d8..f5fce209 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ _user_time_idx = models.Index('(user_id, event_time DESC)') ``` The attribute name after the leading underscore becomes the SQL object name suffix (`{table}_{suffix}`). `models.Index` accepts `DESC`, `WHERE` predicates, and `USING btree (...)`. Sources: `odoo/orm/model_classes.py` (warns at registry build), `odoo/orm/table_objects.py` (Constraint + Index classes). +10. **`res.users._login` is an instance method in Odoo 19**, not a classmethod as in earlier versions. Signature is `def _login(self, credential, user_agent_env)` — there is no `db` parameter. Override it like any normal instance method (`super()._login(credential, user_agent_env)`). When called via `authenticate()` on an empty recordset, `self` carries the right env. Older recipes that build a separate `api.Environment` from `odoo.modules.registry.Registry(db)` no longer apply. Source: `odoo/addons/base/models/res_users.py:760`. 15. **There is NO `sale.subscription` model in Odoo 19** (Enterprise `sale_subscription`). A subscription is a **`sale.order`** with `is_subscription=True`, `plan_id` → **`sale.subscription.plan`** (the recurrence), plus `subscription_state` / `next_invoice_date` / `recurring_monthly`. Any Many2one or relation that targets "a subscription" must point at `sale.order` (filter `domain=[('is_subscription','=',True)]`) — **not** `sale.subscription`, which does not exist and fails at install. The surviving `sale.subscription.*` records are only the plan + wizards/reports (`sale.subscription.plan`, `sale.subscription.report`, `sale.subscription.change.customer.wizard`, `sale.subscription.close.reason.wizard`). Verified on live `nexamain` (odoo-nexa, 19.0): `SELECT model FROM ir_model WHERE model LIKE 'sale.subscription%'`. diff --git a/fusion_login_audit/models/res_users.py b/fusion_login_audit/models/res_users.py index bd8f1293..39e31cce 100644 --- a/fusion_login_audit/models/res_users.py +++ b/fusion_login_audit/models/res_users.py @@ -165,3 +165,31 @@ class ResUsers(models.Model): _credential=credential, ) raise + + def _login(self, credential, user_agent_env): + """Catch the unknown-user branch of upstream _login. + + In Odoo 19 ``_login`` is an *instance* method (not a classmethod as in + earlier versions). Upstream raises ``AccessDenied`` when the login + string does not resolve to any user — at that point no user record + exists, so the ``bad_password`` path in ``_check_credentials`` never + fires. We catch the propagating exception here and write a row with + ``user_id=NULL`` and ``failure_reason='unknown_user'``. + + ``_fc_record_login_event`` already writes through an INDEPENDENT + cursor (``self.env.registry.cursor()``), so the audit row survives + the outer transaction rollback that follows the re-raised + ``AccessDenied``. We never block the re-raise: any audit-side + exception is caught + logged inside the helper. + """ + try: + return super()._login(credential, user_agent_env) + except AccessDenied: + self._fc_record_login_event( + result='failure', + failure_reason='unknown_user', + user_id=False, + attempted_login=(credential or {}).get('login') or 'unknown', + _credential=credential, + ) + raise diff --git a/fusion_login_audit/tests/test_login_audit.py b/fusion_login_audit/tests/test_login_audit.py index 0dcfc3ae..339f8388 100644 --- a/fusion_login_audit/tests/test_login_audit.py +++ b/fusion_login_audit/tests/test_login_audit.py @@ -197,3 +197,33 @@ class TestFusionLoginAuditModel(TransactionCase): 'database'): self.assertNotIn(secret, (row[fname] or ''), f"Password leaked into field {fname}") + + def test_unknown_user_writes_failure_row(self): + """A login attempt for a username that does not exist gets logged + with user_id=NULL and failure_reason='unknown_user'.""" + from odoo.exceptions import AccessDenied + bogus = 'this-user-does-not-exist@example.com' + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('attempted_login', '=', bogus)]) + # NB: manual try/except instead of assertRaises — see comment in + # test_bad_password_writes_failure_row. _login is an instance method + # in Odoo 19 (not a classmethod as in earlier versions); we call it + # on the empty recordset of res.users, which matches what + # authenticate() does internally. + raised = False + try: + self.env['res.users']._login( + {'login': bogus, 'password': 'whatever', + 'type': 'password'}, + {'interactive': False}, + ) + except AccessDenied: + raised = True + self.assertTrue(raised, "AccessDenied must propagate after the audit write") + after = Audit.search_count([('attempted_login', '=', bogus)]) + self.assertEqual(after, before + 1) + row = Audit.search([('attempted_login', '=', bogus)], + order='event_time desc', limit=1) + self.assertFalse(row.user_id) + self.assertEqual(row.failure_reason, 'unknown_user') + self.assertEqual(row.result, 'failure')