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) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@
|
|||||||
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
_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).
|
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%'`.
|
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%'`.
|
||||||
|
|
||||||
|
|||||||
@@ -165,3 +165,31 @@ class ResUsers(models.Model):
|
|||||||
_credential=credential,
|
_credential=credential,
|
||||||
)
|
)
|
||||||
raise
|
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
|
||||||
|
|||||||
@@ -197,3 +197,33 @@ class TestFusionLoginAuditModel(TransactionCase):
|
|||||||
'database'):
|
'database'):
|
||||||
self.assertNotIn(secret, (row[fname] or ''),
|
self.assertNotIn(secret, (row[fname] or ''),
|
||||||
f"Password leaked into field {fname}")
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user