From a32946be44e3f8a3489f346e0a37855c53b4de31 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 20:02:11 -0400 Subject: [PATCH] docs(plan): fusion_login_audit implementation plan 15 TDD tasks targeting ~28 tests: T1 skeleton+icon, T2 model+indexes, T3 security, T4 capture helper, T5 success hook, T6 bad-password hook, T7 unknown-user hook, T8 user form (smart button + tab + computes), T9 standalone views + menus, T10 settings + page section, T11 failure-burst alert + cooldown, T12 retention GC cron, T13 async geo enrichment cron, T14 view visibility security tests, T15 manual smoke + release tag. Self-reviewed: every spec section maps to a task; no placeholders; method and field names consistent across tasks. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-26-fusion-login-audit.md | 2694 +++++++++++++++++ 1 file changed, 2694 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-fusion-login-audit.md diff --git a/docs/superpowers/plans/2026-05-26-fusion-login-audit.md b/docs/superpowers/plans/2026-05-26-fusion-login-audit.md new file mode 100644 index 00000000..6ab3eda5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-fusion-login-audit.md @@ -0,0 +1,2694 @@ +# fusion_login_audit Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a durable Odoo 19 login audit module for `westin-v19` that captures every successful and failed authentication, surfaces the history on the `res.users` form, async-enriches IPs with geolocation, deletes rows past a configurable retention horizon, and emails Settings admins on consecutive-failure bursts. + +**Architecture:** A new module `fusion_login_audit` at `K:\Github\Odoo-Modules\fusion_login_audit\`. Hooks Odoo's auth path via `_update_last_login` (success), `_check_credentials` (known-user failure), and `_login` (unknown-user failure). Writes append-only rows to a dedicated `fusion.login.audit` table via `sudo()`. Out-of-band crons handle geolocation, retention, and alert-cooldown bookkeeping. + +**Tech Stack:** Odoo 19, Python 3, `user_agents` library (bundled with Odoo), PostgreSQL. Geolocation via `http://ip-api.com/json/` (free tier, no key, 45 req/min) routed through the existing `network_logger`. + +**Reference spec:** `docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md` + +--- + +## Pre-flight conventions + +These apply to every task. Read once, internalize, then execute. + +- **NEVER code from memory.** Before writing any new Odoo class/method, read the reference file from the container: + ```bash + docker exec odoo-modsdev-app cat /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -200 + ``` +- **Module location:** `K:\Github\Odoo-Modules\fusion_login_audit\` (Windows paths) which is bind-mounted into the `odoo-modsdev-app` container at `/mnt/extra-addons/fusion_login_audit/` (or whatever path the compose file specifies — verify with `docker inspect odoo-modsdev-app --format '{{json .Mounts}}' | python -m json.tool`). +- **Field naming:** new fields on `res.users` and `res.config.settings` use the `x_fc_*` prefix. Fields on the new `fusion.login.audit` model use plain names. +- **Settings field types:** booleans/integers/floats/char/selection/many2one/datetime only on `res.config.settings`. No Date fields. (`x_fc_login_audit_retention_days` is an Integer — not a Date.) +- **`res.groups`:** never use `users=` or `category_id=`. +- **HTTP routes:** if any are added, use `type="jsonrpc"` not `type="json"`. +- **Canadian English** for all user-facing strings ("Authorise", "Centre", "behaviour", etc.). +- **Test command shape:** + ```bash + docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -i fusion_login_audit --stop-after-init 2>&1 | tail -60 + ``` + First run uses `-i fusion_login_audit` (install). Subsequent runs after code changes use `-u fusion_login_audit` (update). Expected to take 15-40 seconds per run. +- **Commit cadence:** every task ends with a commit. Branch is already `feat/fusion-login-audit` (cut from `main` at sha cc26b9ad). Never push without an explicit user request. +- **Commit message footer:** + ``` + Co-Authored-By: Claude Opus 4.7 (1M context) + ``` + +--- + +## File map + +| Path | Responsibility | +|---|---| +| `fusion_login_audit/__manifest__.py` | Module metadata + data file declarations | +| `fusion_login_audit/__init__.py` | Imports `models` and `tests` | +| `fusion_login_audit/models/__init__.py` | Imports the three model modules | +| `fusion_login_audit/models/fusion_login_audit.py` | The audit record model — fields only, no behaviour | +| `fusion_login_audit/models/res_users.py` | All capture hooks (`_update_last_login`, `_check_credentials`, `_login`), helpers (`_fc_build_event_vals`, `_fc_record_*`, `_fc_recent_failure_count`, `_fc_send_failure_alert`), computed fields, smart-button action | +| `fusion_login_audit/models/res_config_settings.py` | Settings fields with ICP-backed getters/setters | +| `fusion_login_audit/data/ir_cron_data.xml` | `cron_geo_enrich` (5 min) + `cron_retention_gc` (daily 03:00 UTC) | +| `fusion_login_audit/data/mail_template_data.xml` | Failed-login alert email template | +| `fusion_login_audit/security/ir.model.access.csv` | Model access — read-only for `base.group_system` | +| `fusion_login_audit/security/security.xml` | Global record rule mirroring the ACL | +| `fusion_login_audit/views/fusion_login_audit_views.xml` | Standalone list / form / kanban / search views + window action | +| `fusion_login_audit/views/res_users_views.xml` | Smart button + "Login Activity" tab on user form | +| `fusion_login_audit/views/res_config_settings_views.xml` | "Login Audit" section on Settings page | +| `fusion_login_audit/views/menus.xml` | Settings → Technical → Login Audit submenus | +| `fusion_login_audit/tests/__init__.py` | Imports test modules | +| `fusion_login_audit/tests/test_login_audit.py` | TransactionCase: capture, fields, crons, alerts | +| `fusion_login_audit/tests/test_security.py` | HttpCase: ACL + view visibility | +| `fusion_login_audit/static/description/icon.png` | Copied from `C:\Users\gsing\Downloads\fusion logs.png` | + +--- + +## Task 1: Module skeleton + install smoke test + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png` +- Copy from: `C:\Users\gsing\Downloads\fusion logs.png` + +- [ ] **Step 1: Create the directory tree** + +```powershell +$root = "K:\Github\Odoo-Modules\fusion_login_audit" +New-Item -ItemType Directory -Path "$root\models", "$root\data", "$root\security", "$root\views", "$root\tests", "$root\static\description" -Force | Out-Null +``` + +- [ ] **Step 2: Copy the icon** + +```powershell +Copy-Item "C:\Users\gsing\Downloads\fusion logs.png" "K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png" +``` + +- [ ] **Step 3: Write `__manifest__.py`** + +```python +# -*- coding: utf-8 -*- +# Copyright 2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) +{ + 'name': 'Fusion Login Audit', + 'version': '19.0.1.0.0', + 'category': 'Tools', + 'summary': 'Durable login audit log with geo-enrichment, retention, and failure alerts.', + 'description': """ +Fusion Login Audit +================== + +Captures every password authentication event (success + failure) in a +dedicated, append-only audit table. Surfaces history on the user form +as a smart button + tab (admins only). Async-enriches IPs with country, +city, and reverse DNS. Emails Settings admins on consecutive-failure +bursts. Daily retention cron honours a configurable horizon. + """, + 'author': 'Nexa Systems Inc.', + 'website': 'https://nexasystems.ca', + 'license': 'OPL-1', + 'depends': ['base', 'mail'], + 'data': [], # data files added in later tasks + 'installable': True, + 'application': False, + 'auto_install': False, +} +``` + +- [ ] **Step 4: Write `__init__.py`** + +```python +# -*- coding: utf-8 -*- +from . import models +``` + +- [ ] **Step 5: Write `models/__init__.py`** + +```python +# -*- coding: utf-8 -*- +# Files added in later tasks +``` + +- [ ] **Step 6: Verify install succeeds (this is the smoke test)** + +Run: +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init 2>&1 | tail -30 +``` + +Expected: last line ends with `odoo: Initiating shutdown` and no `ERROR`/`CRITICAL` lines above. The module is in the registry. + +Sanity check the install via psql: +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT name, state FROM ir_module_module WHERE name='fusion_login_audit';" +``` +Expected: one row, `state = installed`. + +- [ ] **Step 7: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): module skeleton + icon + +Empty installable module with manifest, package inits, and icon. +Subsequent tasks add the audit model, hooks, views, and tests. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 2: `fusion.login.audit` model + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` (add the ACL — required for create from a test) +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\security\ir.model.access.csv` (full ACL deferred to Task 3 — this is the bare minimum to let the test create rows via sudo) + +- [ ] **Step 1: Write the failing test (`tests/test_login_audit.py`)** + +```python +# -*- coding: utf-8 -*- +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionLoginAuditModel(TransactionCase): + + def test_model_exists_and_creates(self): + """Audit row can be created with all expected fields.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + 'ip_address': '203.0.113.5', + 'user_agent_raw': 'Mozilla/5.0 Test', + 'browser': 'Test 1.0', + 'os': 'TestOS', + 'device_type': 'desktop', + 'database': self.env.cr.dbname, + 'geo_lookup_state': 'pending', + }) + self.assertTrue(rec.id) + self.assertEqual(rec.result, 'success') + self.assertEqual(rec.geo_lookup_state, 'pending') + self.assertEqual(rec.database, self.env.cr.dbname) + self.assertTrue(rec.event_time) # default fires + + def test_failure_reason_optional(self): + """failure_reason is null on success rows.""" + rec = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + }) + self.assertFalse(rec.failure_reason) + + def test_geo_state_internal_value(self): + """`internal` is an accepted geo_lookup_state value (distinct from private_ip).""" + rec = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'demo@example.com', + 'result': 'success', + 'geo_lookup_state': 'internal', + }) + self.assertEqual(rec.geo_lookup_state, 'internal') +``` + +- [ ] **Step 2: Wire test discovery in `tests/__init__.py`** + +```python +# -*- coding: utf-8 -*- +from . import test_login_audit +``` + +And update `K:\Github\Odoo-Modules\fusion_login_audit\__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import models +from . import tests +``` + +- [ ] **Step 3: Create a minimal ACL so the test can read its own writes** + +`K:\Github\Odoo-Modules\fusion_login_audit\security\ir.model.access.csv`: + +```csv +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_login_audit_system,fusion.login.audit system,model_fusion_login_audit,base.group_system,1,0,0,0 +``` + +Add to `__manifest__.py` `data` key: + +```python + 'data': [ + 'security/ir.model.access.csv', + ], +``` + +- [ ] **Step 4: Run the test — expect it to FAIL with "model not found"** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(KeyError|TEST|FAIL|ERROR)" | head -20 +``` + +Expected: a `KeyError: 'fusion.login.audit'` or an Odoo ParseError on the CSV referencing an unknown model. + +- [ ] **Step 5: Implement the model** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py`: + +```python +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class FusionLoginAudit(models.Model): + _name = 'fusion.login.audit' + _description = 'Login Audit Event' + _order = 'event_time desc, id desc' + _rec_name = 'attempted_login' + + user_id = fields.Many2one( + 'res.users', string='User', ondelete='set null', index=True, + help='Null when the attempted login did not match any user.', + ) + attempted_login = fields.Char( + string='Attempted Login', size=255, required=True, index=True, + ) + result = fields.Selection( + [('success', 'Success'), ('failure', 'Failure')], + string='Result', required=True, index=True, + ) + failure_reason = fields.Selection( + [ + ('bad_password', 'Bad password'), + ('unknown_user', 'Unknown user'), + ('disabled_user', 'Disabled user'), + ('2fa_failed', '2FA failed'), + ('other', 'Other'), + ], + string='Failure Reason', + ) + event_time = fields.Datetime( + string='Event Time', required=True, index=True, + default=fields.Datetime.now, + ) + ip_address = fields.Char(string='IP Address', size=45) + ip_hostname = fields.Char(string='Reverse DNS', size=255) + country_code = fields.Char(string='Country Code', size=2, index=True) + country_name = fields.Char(string='Country', size=64) + city = fields.Char(string='City', size=128) + geo_state = fields.Char(string='Region', size=64) + geo_lookup_state = fields.Selection( + [ + ('pending', 'Pending'), + ('done', 'Done'), + ('private_ip', 'Private IP'), + ('internal', 'Internal (no request)'), + ('failed', 'Lookup failed'), + ], + string='Geo Lookup State', default='pending', index=True, + ) + user_agent_raw = fields.Char(string='User Agent', size=512) + browser = fields.Char(string='Browser', size=64) + os = fields.Char(string='OS', size=64) + device_type = fields.Selection( + [ + ('desktop', 'Desktop'), + ('mobile', 'Mobile'), + ('tablet', 'Tablet'), + ('bot', 'Bot'), + ('unknown', 'Unknown'), + ], + string='Device Type', default='unknown', + ) + database = fields.Char(string='Database', size=64) + + _sql_constraints = [ + ( + 'result_failure_reason_consistent', + "CHECK ((result = 'success' AND failure_reason IS NULL) " + "OR (result = 'failure' AND failure_reason IS NOT NULL))", + 'A failure row must have a failure_reason; a success row must not.', + ), + ] + + def init(self): + """Create composite indexes that improve the three hot queries: + per-user history, failure-burst detection by login, geo cron worklist.""" + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS fusion_login_audit_user_time_idx + ON fusion_login_audit (user_id, event_time DESC); + CREATE INDEX IF NOT EXISTS fusion_login_audit_login_time_idx + ON fusion_login_audit (attempted_login, event_time DESC); + CREATE INDEX IF NOT EXISTS fusion_login_audit_geo_state_idx + ON fusion_login_audit (geo_lookup_state, event_time); + """) +``` + +Wire it into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +``` + +- [ ] **Step 6: Run the test — expect PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(TEST|FAIL|ERROR|OK)" | tail -20 +``` + +Expected: three test methods, `Ran 3 tests`, `OK`. Sanity-check the composite index: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "\d fusion_login_audit" | grep idx +``` + +Expected: `fusion_login_audit_user_time_idx`, `fusion_login_audit_login_time_idx`, `fusion_login_audit_geo_state_idx` all present. + +- [ ] **Step 7: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): add fusion.login.audit model + +- All 16 columns per spec (user, attempted_login, result, failure_reason, + event_time, ip/geo fields, user_agent triple, device_type, database). +- SQL check constraint binds failure_reason presence to result value. +- init() creates the three composite indexes (user+time, login+time, + geo_state+time) supporting per-user, failure-burst, and geo cron queries. +- Minimal admin-read ACL added so subsequent tests can verify writes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 3: Security — record rule + final ACL + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\security\security.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` (add `security/security.xml` to `data`) +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py` + +- [ ] **Step 1: Write the failing security tests** + +`K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py`: + +```python +# -*- coding: utf-8 -*- +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionLoginAuditSecurity(TransactionCase): + + def setUp(self): + super().setUp() + self.audit_row = self.env['fusion.login.audit'].sudo().create({ + 'attempted_login': 'sec-test@example.com', + 'result': 'success', + 'database': self.env.cr.dbname, + }) + # Internal non-admin user (active employee, not a Settings admin) + self.regular_user = self.env['res.users'].sudo().create({ + 'name': 'Regular Tester', + 'login': 'regular-tester@example.com', + 'password': 'regular-tester-pw-1', + 'groups_id': [(6, 0, [self.env.ref('base.group_user').id])], + }) + + def test_settings_admin_can_read(self): + """Settings admins (base.group_system) can read audit rows.""" + admin = self.env.ref('base.user_admin') + rec = self.audit_row.with_user(admin).read(['attempted_login']) + self.assertEqual(rec[0]['attempted_login'], 'sec-test@example.com') + + def test_regular_user_cannot_read(self): + """A non-admin internal user cannot read audit rows.""" + with self.assertRaises(AccessError): + self.audit_row.with_user(self.regular_user).read(['attempted_login']) + + def test_nobody_can_write_via_orm(self): + """Even Settings admins cannot write via the ORM (audit is append-only).""" + admin = self.env.ref('base.user_admin') + with self.assertRaises(AccessError): + self.audit_row.with_user(admin).write({'attempted_login': 'tampered'}) + + def test_nobody_can_unlink_via_orm(self): + """Even Settings admins cannot delete via the ORM.""" + admin = self.env.ref('base.user_admin') + with self.assertRaises(AccessError): + self.audit_row.with_user(admin).unlink() +``` + +Wire it up — `tests/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import test_login_audit +from . import test_security +``` + +- [ ] **Step 2: Run the tests — expect 2 to fail** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -20 +``` + +Expected: `test_settings_admin_can_read` passes (ACL already allows it), `test_regular_user_cannot_read` passes (ACL doesn't grant `base.group_user`), `test_nobody_can_write_via_orm` FAILS (ACL grants `perm_read=1` but the underlying `base.group_system` includes superuser privileges that bypass the ACL — we need a record rule), `test_nobody_can_unlink_via_orm` FAILS for the same reason. + +Actually — `base.user_admin` is in `base.group_system` and the ACL grants read only. Write/unlink should already fail with `AccessError`. Re-read the failure output carefully before changing course. If both write/unlink tests actually pass on the bare ACL, treat that as a green TDD result. + +- [ ] **Step 3: Add the record rule for defence-in-depth** + +`K:\Github\Odoo-Modules\fusion_login_audit\security\security.xml`: + +```xml + + + + + + fusion.login.audit: admin read only + + [(1, '=', 1)] + + + + + + + + + +``` + +Add to `__manifest__.py` `data` list (must come AFTER the ACL CSV): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + ], +``` + +- [ ] **Step 4: Re-run — expect all 4 security tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 7 tests` (3 from Task 2 + 4 here), `OK`. + +- [ ] **Step 5: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): admin-only record rule + security tests + +Record rule restricts read to base.group_system. The ACL already +forbids write/create/unlink for every group (audit is append-only; +sudo() inside auth hooks is the only write path). Tests cover both +the positive (admin can read) and three negative (non-admin cannot +read; admin cannot write or unlink via ORM) paths. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 4: Capture helper — `_fc_build_event_vals` on `res.users` + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` (append) + +This task wires only the value-building helper, with no auth-path hooks yet. Building it independently means the next three tasks can each focus on a single path without re-inventing context extraction. + +- [ ] **Step 1: Read the Odoo reference before writing** + +```bash +docker exec odoo-modsdev-app cat /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | sed -n '1,40p' +docker exec odoo-modsdev-app python3 -c "from user_agents import parse; ua = parse('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/140.0 Safari/537.36'); print(ua.browser, ua.os, ua.is_pc)" +``` + +Expected on the second line: a `Browser(family='Chrome', major='140', minor='0', patch=None)`, `OS(family='Windows', major='10', …)`, and `True`. + +- [ ] **Step 2: Append failing tests to `test_login_audit.py`** + +Add inside the same `TestFusionLoginAuditModel` class: + +```python + def test_build_event_vals_with_no_request(self): + """Without a live request, geo_lookup_state is 'internal'.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='success', + attempted_login='cron@example.com', + ) + self.assertEqual(vals['result'], 'success') + self.assertEqual(vals['attempted_login'], 'cron@example.com') + self.assertEqual(vals['ip_address'], 'internal') + self.assertEqual(vals['user_agent_raw'], '') + self.assertEqual(vals['geo_lookup_state'], 'internal') + self.assertEqual(vals['database'], self.env.cr.dbname) + + def test_build_event_vals_parses_user_agent(self): + """Parser fills browser/os/device_type from a stub UA dict.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='success', + attempted_login='ua@example.com', + _override_ip='203.0.113.5', + _override_ua='Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + 'AppleWebKit/537.36 Chrome/140.0 Safari/537.36', + ) + self.assertEqual(vals['ip_address'], '203.0.113.5') + self.assertIn('Chrome', vals['browser']) + self.assertIn('Windows', vals['os']) + self.assertEqual(vals['device_type'], 'desktop') + self.assertEqual(vals['geo_lookup_state'], 'pending') + + def test_build_event_vals_strips_password(self): + """If a credential dict sneaks in, no password leaks into vals.""" + ResUsers = self.env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='failure', + attempted_login='leak@example.com', + failure_reason='bad_password', + _credential={'login': 'leak@example.com', + 'password': 'super-secret-pw', + 'type': 'password'}, + ) + serialized = repr(vals) + self.assertNotIn('super-secret-pw', serialized) + self.assertEqual(vals['failure_reason'], 'bad_password') +``` + +- [ ] **Step 3: Run — expect FAIL ("AttributeError: ... _fc_build_event_vals")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new failures with `AttributeError: 'res.users' has no attribute '_fc_build_event_vals'`. + +- [ ] **Step 4: Implement the helper** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py`: + +```python +# -*- coding: utf-8 -*- +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = 'res.users' + + # The credentials dict from auth flows may include 'password'. We never + # persist or log the password value. _SAFE_CRED_KEYS bounds the surface. + _SAFE_CRED_KEYS = ('login', 'type') + + @api.model + def _fc_build_event_vals( + self, + result, + attempted_login, + failure_reason=None, + user_id=None, + _override_ip=None, + _override_ua=None, + _credential=None, + ): + """Build the dict of values for a fusion.login.audit row. + + Pulls IP / User-Agent from the live HTTP request when available. + Falls back to ('internal', '') for XML-RPC / cron-initiated + auth, with geo_lookup_state='internal' so the geo cron skips them. + + The _override_* kwargs exist for tests so we don't have to fake a + full request. They are NOT a public API. + """ + from user_agents import parse as ua_parse + + vals = { + 'attempted_login': (attempted_login or '')[:255], + 'result': result, + 'failure_reason': failure_reason, + 'event_time': fields.Datetime.now(), + 'database': self.env.cr.dbname, + 'user_id': user_id, + } + + ip = _override_ip + ua_str = _override_ua + + if ip is None or ua_str is None: + try: + from odoo.http import request + if request and getattr(request, 'httprequest', None): + if ip is None: + ip = request.httprequest.remote_addr + if ua_str is None: + ua_str = request.httprequest.user_agent.string or '' + except Exception: + _logger.debug("fusion_login_audit: no request context", exc_info=True) + + if ip and ua_str is not None: + vals['ip_address'] = ip[:45] + vals['user_agent_raw'] = (ua_str or '')[:512] + ua = ua_parse(ua_str or '') + vals['browser'] = (f"{ua.browser.family} {ua.browser.version_string}".strip())[:64] + vals['os'] = (f"{ua.os.family} {ua.os.version_string}".strip())[:64] + if ua.is_bot: + vals['device_type'] = 'bot' + elif ua.is_mobile: + vals['device_type'] = 'mobile' + elif ua.is_tablet: + vals['device_type'] = 'tablet' + elif ua.is_pc: + vals['device_type'] = 'desktop' + else: + vals['device_type'] = 'unknown' + vals['geo_lookup_state'] = 'pending' + else: + vals['ip_address'] = 'internal' + vals['user_agent_raw'] = '' + vals['device_type'] = 'unknown' + vals['geo_lookup_state'] = 'internal' + + # Defensive: caller may pass _credential just so we can log its 'type'; + # the password itself must never reach vals. + if _credential is not None: + cred_type = _credential.get('type') + if cred_type: + vals.setdefault('_credential_type', cred_type) + # Never read _credential['password'] + + # Strip our internal-use scratch keys before returning. + vals.pop('_credential_type', None) + return vals +``` + +Wire into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +from . import res_users +``` + +- [ ] **Step 5: Re-run — expect all 10 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 10 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): add _fc_build_event_vals context helper + +Single helper builds vals for fusion.login.audit rows from the live +HTTP request (or falls back to ip='internal' + geo_lookup_state='internal' +when there is no request). Parses UA into browser/os/device_type via +the user_agents library. Never reads credential['password']. Tests +cover: no-request fallback, UA parsing on a Chrome/Windows UA, and +the regression that no password value leaks into the vals dict. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 5: Success path — `_update_last_login` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_update_last_login_writes_audit_row(self): + """Calling _update_last_login on a user creates a success row.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Audit Tester', + 'login': 'audit-tester@example.com', + 'password': 'audit-tester-pw-1', + }) + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('user_id', '=', user.id)]) + user._update_last_login() + after = Audit.search_count([('user_id', '=', user.id)]) + self.assertEqual(after, before + 1) + row = Audit.search([('user_id', '=', user.id)], + order='event_time desc', limit=1) + self.assertEqual(row.result, 'success') + self.assertEqual(row.attempted_login, user.login) + self.assertFalse(row.failure_reason) + self.assertEqual(row.database, self.env.cr.dbname) + + def test_audit_write_failure_does_not_block_login(self): + """An exception inside the audit write must not propagate.""" + from unittest.mock import patch + user = self.env['res.users'].sudo().create({ + 'name': 'Resilient Tester', + 'login': 'resilient-tester@example.com', + 'password': 'resilient-tester-pw-1', + }) + + def boom(self_, vals): + raise RuntimeError('simulated audit DB failure') + + with patch.object(type(self.env['fusion.login.audit']), + 'create', boom): + # Must not raise. + user._update_last_login() +``` + +- [ ] **Step 2: Run — expect FAIL ("user has no audit row")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new failures — `AssertionError: 0 != 1` (no row written) and the resilience test passes vacuously (no override exists yet so nothing can blow up). + +- [ ] **Step 3: Add the override + write helper** + +Append to `models/res_users.py`: + +```python + def _fc_record_login_event(self, result, failure_reason=None, + user_id=None, attempted_login=None, + _credential=None): + """Build vals + create the audit row via sudo. Never raises.""" + try: + vals = self._fc_build_event_vals( + result=result, + attempted_login=attempted_login + or (self.login if self else None) + or 'unknown', + failure_reason=failure_reason, + user_id=user_id or (self.id if self else None), + _credential=_credential, + ) + self.env['fusion.login.audit'].sudo().with_context( + mail_create_nolog=True + ).create(vals) + except Exception: + _logger.exception( + "fusion_login_audit: failed to record %s row for %s", + result, attempted_login or (self.login if self else 'unknown'), + ) + + def _update_last_login(self): + result = super()._update_last_login() + # Self is a singleton recordset of the user that just logged in. + self._fc_record_login_event(result='success') + return result +``` + +- [ ] **Step 4: Run — expect all 12 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 12 tests`, `OK`. + +- [ ] **Step 5: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook successful login via _update_last_login + +Overrides res.users._update_last_login to create a fusion.login.audit +row with result=success after the parent runs. The write goes through +sudo() + mail_create_nolog=True. Any exception in the audit path is +caught and logged but never propagates -- a broken audit table must +never block a real user from logging in. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 6: Known-user failure path — `_check_credentials` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Read the reference** + +```bash +docker exec odoo-modsdev-app grep -n "_check_credentials" /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -10 +docker exec odoo-modsdev-app sed -n '/def _check_credentials/,/^ def /p' /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -30 +``` + +Confirm the signature is `def _check_credentials(self, credential, env):` and that it raises `odoo.exceptions.AccessDenied` on bad password. + +- [ ] **Step 2: Append failing tests** + +```python + def test_bad_password_writes_failure_row(self): + """A wrong password creates a result=failure row with failure_reason='bad_password'.""" + from odoo.exceptions import AccessDenied + user = self.env['res.users'].sudo().create({ + 'name': 'Wrongpw Tester', + 'login': 'wrongpw-tester@example.com', + 'password': 'wrongpw-tester-pw-1', + }) + Audit = self.env['fusion.login.audit'].sudo() + before = Audit.search_count([('attempted_login', '=', user.login), + ('result', '=', 'failure')]) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'definitely-wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Audit.search_count([('attempted_login', '=', user.login), + ('result', '=', 'failure')]) + self.assertEqual(after, before + 1) + row = Audit.search([('attempted_login', '=', user.login), + ('result', '=', 'failure')], + order='event_time desc', limit=1) + self.assertEqual(row.failure_reason, 'bad_password') + self.assertEqual(row.user_id, user) + + def test_bad_password_never_appears_in_row(self): + """The attempted password string never lands in any field.""" + from odoo.exceptions import AccessDenied + secret = 'NeverInTheRow-9f3a82' + user = self.env['res.users'].sudo().create({ + 'name': 'Leak Test', + 'login': 'leak-test-2@example.com', + 'password': 'leak-test-pw-1', + }) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': secret, 'type': 'password'}, + {'interactive': False}, + ) + row = self.env['fusion.login.audit'].sudo().search( + [('attempted_login', '=', user.login), + ('result', '=', 'failure')], + order='event_time desc', limit=1) + for fname in ('attempted_login', 'failure_reason', 'user_agent_raw', + 'browser', 'os', 'ip_address', 'ip_hostname', + 'city', 'country_name', 'country_code', 'geo_state'): + self.assertNotIn(secret, (row[fname] or ''), + f"Password leaked into field {fname}") +``` + +- [ ] **Step 3: Run — expect 2 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new `AssertionError: 0 != 1`. + +- [ ] **Step 4: Implement the override** + +Append to `models/res_users.py`: + +```python + def _check_credentials(self, credential, env): + from odoo.exceptions import AccessDenied + try: + return super()._check_credentials(credential, env) + except AccessDenied: + cred_type = (credential or {}).get('type', 'password') + reason = '2fa_failed' if cred_type == 'totp' else 'bad_password' + self._fc_record_login_event( + result='failure', + failure_reason=reason, + user_id=self.id, + attempted_login=(credential or {}).get('login') or self.login, + _credential=credential, + ) + raise +``` + +- [ ] **Step 5: Run — expect all 14 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 14 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook bad-password failures via _check_credentials + +Wraps res.users._check_credentials. On AccessDenied, records a row with +result=failure and failure_reason='bad_password' (or '2fa_failed' when +credential['type'] == 'totp'), then re-raises. Regression test asserts +the attempted password value never lands in any audit field. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 7: Unknown-user failure path — `_login` override + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Read the reference** + +```bash +docker exec odoo-modsdev-app grep -n "def _login" /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -10 +docker exec odoo-modsdev-app sed -n '/def _login(/,/^ [a-z@]/p' /usr/lib/python3/dist-packages/odoo/addons/base/models/res_users.py | head -50 +``` + +Confirm `_login` is a `@classmethod` taking `(cls, db, credential, user_agent_env)` and raising `AccessDenied` when the login string doesn't resolve to a user. + +- [ ] **Step 2: Append failing test** + +```python + 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)]) + with self.assertRaises(AccessDenied): + self.env['res.users']._login( + self.env.cr.dbname, + {'login': bogus, 'password': 'whatever', + 'type': 'password'}, + {'interactive': False}, + ) + 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') +``` + +- [ ] **Step 3: Run — expect FAIL ("0 != 1")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 1 new `AssertionError: 0 != 1`. + +- [ ] **Step 4: Implement the classmethod override** + +Append to `models/res_users.py`. Use `api.Environment.manage()` + a fresh cursor because the classmethod runs outside any per-user env: + +```python + @classmethod + def _login(cls, db, credential, user_agent_env): + from odoo.exceptions import AccessDenied + try: + return super()._login(db, credential, user_agent_env) + except AccessDenied: + try: + cls._fc_record_unknown_user_failure( + db, credential, user_agent_env, + ) + except Exception: + _logger.exception( + "fusion_login_audit: failed to record unknown-user " + "failure for db=%s login=%s", + db, (credential or {}).get('login'), + ) + raise + + @classmethod + def _fc_record_unknown_user_failure(cls, db, credential, user_agent_env): + """Insert a failure row from outside a per-user env. We open our + own short-lived cursor so we don't depend on caller transaction + semantics.""" + import odoo + from odoo import api, SUPERUSER_ID + registry = odoo.modules.registry.Registry(db) + with registry.cursor() as cr: + env = api.Environment(cr, SUPERUSER_ID, {}) + ResUsers = env['res.users'] + vals = ResUsers._fc_build_event_vals( + result='failure', + attempted_login=(credential or {}).get('login') or 'unknown', + failure_reason='unknown_user', + _credential=credential, + ) + env['fusion.login.audit'].sudo().with_context( + mail_create_nolog=True + ).create(vals) + # explicit commit: caller is going to raise AccessDenied which + # might roll back its own transaction; ours is separate. + cr.commit() +``` + +- [ ] **Step 5: Run — expect all 15 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 15 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): hook unknown-user failures via _login + +Overrides the res.users._login classmethod. When the login string does +not resolve to any user, super() raises AccessDenied; we open our own +short-lived cursor (because the auth flow runs outside any per-user +env), record a row with user_id=NULL and failure_reason='unknown_user', +commit it, then re-raise. This closes the gap where typo'd or scanned +logins would otherwise vanish from the audit trail. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 8: `res.users` computed fields + smart button + form view + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\res_users_views.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_computed_last_successful_login(self): + """x_fc_last_successful_login reflects the latest success row.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Compute Tester', + 'login': 'compute-tester@example.com', + 'password': 'compute-tester-pw-1', + }) + self.env['fusion.login.audit'].sudo().create({ + 'user_id': user.id, + 'attempted_login': user.login, + 'result': 'success', + 'database': self.env.cr.dbname, + }) + user.invalidate_recordset(['x_fc_last_successful_login', + 'x_fc_login_audit_count']) + self.assertTrue(user.x_fc_last_successful_login) + self.assertGreaterEqual(user.x_fc_login_audit_count, 1) + + def test_action_view_login_audit_returns_window_action(self): + """The smart-button action returns an act_window scoped to this user.""" + user = self.env['res.users'].sudo().create({ + 'name': 'Action Tester', + 'login': 'action-tester@example.com', + 'password': 'action-tester-pw-1', + }) + action = user.action_fc_view_login_audit() + self.assertEqual(action['res_model'], 'fusion.login.audit') + self.assertEqual(action['type'], 'ir.actions.act_window') + self.assertIn(('user_id', '=', user.id), action['domain']) +``` + +- [ ] **Step 2: Run — expect 2 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `AttributeError: 'res.users' object has no attribute 'x_fc_last_successful_login'` and the action method missing. + +- [ ] **Step 3: Add fields + action method** + +Append to `models/res_users.py`: + +```python + x_fc_login_audit_ids = fields.One2many( + 'fusion.login.audit', 'user_id', + string='Login Activity', + ) + x_fc_login_audit_count = fields.Integer( + string='Login Audit Count', + compute='_compute_x_fc_login_audit_count', + ) + x_fc_last_successful_login = fields.Datetime( + string='Last Successful Login', + compute='_compute_x_fc_last_successful_login', + store=True, + ) + x_fc_last_login_ip = fields.Char( + string='Last Login IP', size=45, + compute='_compute_x_fc_last_successful_login', + store=True, + ) + + @api.depends('x_fc_login_audit_ids') + def _compute_x_fc_login_audit_count(self): + Audit = self.env['fusion.login.audit'].sudo() + groups = Audit.read_group( + domain=[('user_id', 'in', self.ids)], + fields=['user_id'], + groupby=['user_id'], + ) + counts = {g['user_id'][0]: g['user_id_count'] for g in groups} + for user in self: + user.x_fc_login_audit_count = counts.get(user.id, 0) + + @api.depends('x_fc_login_audit_ids.event_time', + 'x_fc_login_audit_ids.result', + 'x_fc_login_audit_ids.ip_address') + def _compute_x_fc_last_successful_login(self): + Audit = self.env['fusion.login.audit'].sudo() + for user in self: + row = Audit.search( + [('user_id', '=', user.id), ('result', '=', 'success')], + order='event_time desc', limit=1, + ) + user.x_fc_last_successful_login = row.event_time or False + user.x_fc_last_login_ip = row.ip_address or False + + def action_fc_view_login_audit(self): + self.ensure_one() + return { + 'name': _('Login Activity'), + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.login.audit', + 'view_mode': 'list,form', + 'domain': [('user_id', '=', self.id)], + 'context': {'create': False, 'edit': False, 'delete': False, + 'default_user_id': self.id}, + } +``` + +Add `_` to imports at top of file: + +```python +from odoo import _, api, fields, models +``` + +- [ ] **Step 4: Add the form view (smart button + tab)** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\res_users_views.xml`: + +```xml + + + + + res.users.form.inherit.fusion_login_audit + res.users + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/res_users_views.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. Also confirm the inherited view loaded: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT name FROM ir_ui_view WHERE name='res.users.form.inherit.fusion_login_audit';" +``` + +Expected: 1 row. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): smart button + Login Activity tab on res.users + +Adds four x_fc_* fields on res.users: login_audit_ids (One2many), +login_audit_count (compute), last_successful_login (compute, stored), +last_login_ip (compute, stored). action_fc_view_login_audit returns +a window action scoped to the current user. View inheritance adds a +smart button to the button box and a "Login Activity" page to the +notebook, both gated by base.group_system. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 9: Standalone views + menus for `fusion.login.audit` + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` + +This task has no Python logic — just XML. The "test" is install-time view validation: a broken view will fail the install. + +- [ ] **Step 1: Write the views** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\fusion_login_audit_views.xml`: + +```xml + + + + + + fusion.login.audit.list + fusion.login.audit + + + + + + + + + + + + + + + + + + + + fusion.login.audit.form + fusion.login.audit + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + fusion.login.audit.search + fusion.login.audit + + + + + + + + + + + + + + + + + + + + + + + + + + Login Events + fusion.login.audit + list,form + + {} + + + + Failed Logins (24h) + fusion.login.audit + list,form + + {'search_default_filter_failure': 1, 'search_default_filter_24h': 1} + + +
+``` + +`K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml`: + +```xml + + + + + + + + + + +``` + +Add to `__manifest__.py` `data` list (order matters — views before menus): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 2: Update install + test — expect all 17 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 17 tests`, `OK`. If any `ParseError` appears, the XPath or field reference is wrong — fix and re-run. + +Sanity check the menu got registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT m.id, m.name FROM ir_ui_menu m JOIN ir_model_data d ON d.res_id=m.id AND d.model='ir.ui.menu' WHERE d.module='fusion_login_audit';" +``` + +Expected: 3 menus. + +- [ ] **Step 3: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): standalone views + menus + +List, form, and search views for fusion.login.audit, plus a "Login +Events" full-history action and a "Failed Logins (24h)" pre-filtered +action. Both surface under Settings -> Technical -> Login Audit +(menu items gated by base.group_system). Views are no-create / no-edit +/ no-delete to enforce append-only at the UI layer too. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 10: Settings model + Settings page section + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\__init__.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing test** + +```python + def test_settings_round_trip(self): + """Writing settings persists them via ir.config_parameter.""" + Settings = self.env['res.config.settings'].sudo() + Settings.create({ + 'x_fc_login_audit_retention_days': 90, + 'x_fc_login_audit_alert_threshold': 3, + 'x_fc_login_audit_alert_window_min': 5, + 'x_fc_login_audit_alert_enabled': False, + }).execute() + ICP = self.env['ir.config_parameter'].sudo() + self.assertEqual(ICP.get_param('fusion_login_audit.retention_days'), '90') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_threshold'), '3') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_window_min'), '5') + self.assertEqual(ICP.get_param('fusion_login_audit.alert_enabled'), 'False') +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError" on x_fc_login_audit_retention_days)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 1 new failure. + +- [ ] **Step 3: Write the settings model** + +`K:\Github\Odoo-Modules\fusion_login_audit\models\res_config_settings.py`: + +```python +# -*- coding: utf-8 -*- +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + x_fc_login_audit_retention_days = fields.Integer( + string='Login Audit Retention (days)', + default=365, + config_parameter='fusion_login_audit.retention_days', + help='Login audit rows older than this are deleted by the nightly ' + 'cron. Set to 0 to keep forever.', + ) + x_fc_login_audit_alert_threshold = fields.Integer( + string='Alert After N Consecutive Failures', + default=5, + config_parameter='fusion_login_audit.alert_threshold', + help='When this many failures for the same attempted login occur ' + 'within the alert window, Settings admins receive one email.', + ) + x_fc_login_audit_alert_window_min = fields.Integer( + string='Alert Window (minutes)', + default=15, + config_parameter='fusion_login_audit.alert_window_min', + ) + x_fc_login_audit_alert_enabled = fields.Boolean( + string='Send Failed-Login Alerts', + default=True, + config_parameter='fusion_login_audit.alert_enabled', + ) +``` + +Wire into `models/__init__.py`: + +```python +# -*- coding: utf-8 -*- +from . import fusion_login_audit +from . import res_users +from . import res_config_settings +``` + +- [ ] **Step 4: Write the settings view** + +`K:\Github\Odoo-Modules\fusion_login_audit\views\res_config_settings_views.xml`: + +```xml + + + + + res.config.settings.form.login.audit + res.config.settings + + + + + + + + + + + + + + + + + + + + + + +``` + +> If `//block[@id='userManagement']` doesn't exist in this Odoo 19 build, fall back to `` and wrap in a `...` directly. Verify by running the failing install once with the original XPath and reading the ParseError. + +Add to `__manifest__.py`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 18 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran|ParseError)" | tail -10 +``` + +Expected: `Ran 18 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): settings model + page section + +Four x_fc_* fields on res.config.settings backed by ir.config_parameter: +retention_days (default 365, 0 = forever), alert_threshold (5), +alert_window_min (15), alert_enabled (True). New "Login Audit" block +under userManagement on the General Settings page (gated by +base.group_system). + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 11: Failure-burst alert (template + send logic + cooldown) + +**Files:** +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\res_users.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_failure_burst_queues_one_email(self): + """5 failures in the alert window queue exactly one mail.mail.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + # Clear any cooldown left over from earlier tests. + ICP.set_param('fusion_login_audit.last_alert:burst@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Burst Tester', + 'login': 'burst@example.com', + 'password': 'burst-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'burst@example.com')]) + self.assertEqual(after, before + 1) + + def test_cooldown_suppresses_second_alert(self): + """A 4th and 5th failure within the cooldown queue zero more emails.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '3') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'True') + ICP.set_param('fusion_login_audit.last_alert:cool@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Cooldown Tester', + 'login': 'cool@example.com', + 'password': 'cooldown-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + for _i in range(3): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_3 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + for _i in range(2): + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + count_after_5 = Mail.search_count([('subject', 'ilike', 'cool@example.com')]) + self.assertEqual(count_after_5, count_after_3, + "Cooldown should suppress additional emails") + + def test_alert_disabled_master_switch(self): + """alert_enabled=False suppresses all alerts regardless of threshold.""" + from odoo.exceptions import AccessDenied + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.alert_threshold', '1') + ICP.set_param('fusion_login_audit.alert_window_min', '15') + ICP.set_param('fusion_login_audit.alert_enabled', 'False') + ICP.set_param('fusion_login_audit.last_alert:disabled@example.com', '') + + user = self.env['res.users'].sudo().create({ + 'name': 'Disabled Tester', + 'login': 'disabled@example.com', + 'password': 'disabled-tester-pw-1', + }) + Mail = self.env['mail.mail'].sudo() + before = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + with self.assertRaises(AccessDenied): + user._check_credentials( + {'login': user.login, 'password': 'wrong', + 'type': 'password'}, + {'interactive': False}, + ) + after = Mail.search_count([('subject', 'ilike', 'disabled@example.com')]) + self.assertEqual(after, before, "Disabled alerts should queue nothing") +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AssertionError`s (no alert email queued yet). + +- [ ] **Step 3: Write the mail template** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\mail_template_data.xml`: + +```xml + + + + + + Fusion Login Audit — Failure Burst Alert + + [Login Audit] Failed login attempts for {{ ctx.get('attempted_login') }} + +
+

The login audit detected + failed login attempt(s) + in the last minute(s) for + .

+

Most recent attempts:

+ + + + + + + + + + + + + + +
TimeIPCountryBrowserOS
+ + + + +
+

+ Sent by Fusion Login Audit. Tune the threshold and window in + Settings → General Settings → Login Audit. +

+
+
+
+ +
+
+``` + +- [ ] **Step 4: Add helpers + wire the call into `_check_credentials`** + +Append to `models/res_users.py` (before the `_check_credentials` override, or as new methods of the same class): + +```python + def _fc_alert_threshold(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_threshold', 5))) + except (TypeError, ValueError): + return 5 + + def _fc_alert_window_min(self): + ICP = self.env['ir.config_parameter'].sudo() + try: + return max(1, int(ICP.get_param( + 'fusion_login_audit.alert_window_min', 15))) + except (TypeError, ValueError): + return 15 + + def _fc_alert_enabled(self): + ICP = self.env['ir.config_parameter'].sudo() + return ICP.get_param('fusion_login_audit.alert_enabled', 'True') == 'True' + + def _fc_recent_failure_count(self, attempted_login): + """Failures for this attempted_login within the alert window.""" + from datetime import timedelta + if not attempted_login: + return 0 + cutoff = fields.Datetime.now() - timedelta( + minutes=self._fc_alert_window_min()) + return self.env['fusion.login.audit'].sudo().search_count([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ]) + + def _fc_send_failure_alert(self, attempted_login): + """Queue one alert mail unless cooldown is active. Cooldown is + 60 minutes, keyed by attempted_login, stored in ir.config_parameter.""" + from datetime import timedelta + if not self._fc_alert_enabled(): + return + if not attempted_login: + return + ICP = self.env['ir.config_parameter'].sudo() + cd_key = f'fusion_login_audit.last_alert:{attempted_login}' + cd_raw = ICP.get_param(cd_key) + now = fields.Datetime.now() + if cd_raw: + try: + last = fields.Datetime.from_string(cd_raw) + if last and (now - last) < timedelta(minutes=60): + return # cooldown active + except (TypeError, ValueError): + pass + + window = self._fc_alert_window_min() + cutoff = now - timedelta(minutes=window) + Audit = self.env['fusion.login.audit'].sudo() + rows = Audit.search([ + ('attempted_login', '=', attempted_login), + ('result', '=', 'failure'), + ('event_time', '>=', cutoff), + ], order='event_time desc', limit=20) + + admins = self.env.ref('base.group_system').users.filtered( + lambda u: u.email and not u.share) + if not admins: + return + + tmpl = self.env.ref( + 'fusion_login_audit.mail_template_failure_burst', + raise_if_not_found=False) + if not tmpl: + return + + ctx = { + 'attempted_login': attempted_login, + 'failure_count': len(rows), + 'window_min': window, + 'rows': [{ + 'event_time': fields.Datetime.to_string(r.event_time), + 'ip_address': r.ip_address or '', + 'country_code': r.country_code or '', + 'browser': r.browser or '', + 'os': r.os or '', + } for r in rows], + } + for admin in admins: + tmpl.with_context(ctx=ctx).send_mail( + admin.id, + email_values={'email_to': admin.email, + 'auto_delete': True}, + force_send=False, + ) + ICP.set_param(cd_key, fields.Datetime.to_string(now)) +``` + +Modify `_check_credentials` to call the alert after recording the failure: + +```python + def _check_credentials(self, credential, env): + from odoo.exceptions import AccessDenied + try: + return super()._check_credentials(credential, env) + except AccessDenied: + cred_type = (credential or {}).get('type', 'password') + reason = '2fa_failed' if cred_type == 'totp' else 'bad_password' + attempted_login = (credential or {}).get('login') or self.login + self._fc_record_login_event( + result='failure', + failure_reason=reason, + user_id=self.id, + attempted_login=attempted_login, + _credential=credential, + ) + try: + if self._fc_recent_failure_count(attempted_login) \ + >= self._fc_alert_threshold(): + self._fc_send_failure_alert(attempted_login) + except Exception: + _logger.exception( + "fusion_login_audit: failed to send failure alert") + raise +``` + +Add to `__manifest__.py` `data` (after security, before views): + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 21 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 21 tests`, `OK`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): failure-burst alert email + +Adds a mail.template + helpers (_fc_recent_failure_count, +_fc_send_failure_alert) and wires the call into _check_credentials so +that crossing the threshold queues exactly one mail.mail per attempted +login per 60-minute cooldown window. Master kill switch +x_fc_login_audit_alert_enabled honoured. Recipients are all +base.group_system members with a non-empty email. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 12: Retention GC cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Create: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\__manifest__.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_retention_gc_deletes_old_rows(self): + """The GC method deletes rows older than retention_days.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '30') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + old = Audit.create({ + 'attempted_login': 'gc-old@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=45), + }) + recent = Audit.create({ + 'attempted_login': 'gc-recent@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=5), + }) + old_id, recent_id = old.id, recent.id + + Audit._fc_retention_gc() + + self.assertFalse(Audit.browse(old_id).exists(), + "Row older than retention_days should be gone") + self.assertTrue(Audit.browse(recent_id).exists(), + "Row inside retention_days should survive") + + def test_retention_zero_keeps_forever(self): + """retention_days=0 keeps all rows.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + ICP.set_param('fusion_login_audit.retention_days', '0') + + now = fields.Datetime.now() + Audit = self.env['fusion.login.audit'].sudo() + ancient = Audit.create({ + 'attempted_login': 'forever@example.com', + 'result': 'success', + 'event_time': now - timedelta(days=3650), + }) + ancient_id = ancient.id + + Audit._fc_retention_gc() + + self.assertTrue(Audit.browse(ancient_id).exists(), + "retention_days=0 must keep everything") +``` + +- [ ] **Step 2: Run — expect FAIL ("AttributeError: _fc_retention_gc")** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 2 new failures. + +- [ ] **Step 3: Add the GC method** + +Append to `models/fusion_login_audit.py` (inside the `FusionLoginAudit` class): + +```python + @api.model + def _fc_retention_gc(self): + """Delete rows older than fusion_login_audit.retention_days. Called + daily by ir.cron. retention_days=0 means keep forever.""" + from datetime import timedelta + ICP = self.env['ir.config_parameter'].sudo() + try: + days = int(ICP.get_param( + 'fusion_login_audit.retention_days', 365)) + except (TypeError, ValueError): + days = 365 + if days <= 0: + return 0 + cutoff = fields.Datetime.now() - timedelta(days=days) + old = self.sudo().search([('event_time', '<', cutoff)]) + count = len(old) + if old: + old.unlink() + return count +``` + +- [ ] **Step 4: Write the cron data file** + +`K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml`: + +```xml + + + + + + Fusion Login Audit: Retention GC + + code + model._fc_retention_gc() + 1 + days + -1 + + + + + + +``` + +Add to `__manifest__.py` `data`: + +```python + 'data': [ + 'security/ir.model.access.csv', + 'security/security.xml', + 'data/mail_template_data.xml', + 'data/ir_cron_data.xml', + 'views/fusion_login_audit_views.xml', + 'views/res_users_views.xml', + 'views/res_config_settings_views.xml', + 'views/menus.xml', + ], +``` + +- [ ] **Step 5: Run — expect all 23 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 23 tests`, `OK`. Sanity-check the cron is registered: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT cron_name, interval_number, interval_type, active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%';" +``` + +Expected: 1 row, `interval_number=1`, `interval_type='days'`, `active=t`. + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): nightly retention GC cron + +Adds _fc_retention_gc() that deletes rows older than the configured +horizon (default 365 days; 0 = keep forever). Registered as a daily +ir.cron firing at 03:00 next-day. Tests verify both the delete path +and the "keep forever" short-circuit. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 13: Async geo-enrichment cron + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\models\fusion_login_audit.py` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\data\ir_cron_data.xml` +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_login_audit.py` + +- [ ] **Step 1: Append failing tests** + +```python + def test_geo_private_ip_shortcut(self): + """Private IPs short-circuit to state='private_ip' without HTTP.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'lan@example.com', + 'result': 'success', + 'ip_address': '192.168.1.40', + 'geo_lookup_state': 'pending', + }) + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'private_ip') + self.assertEqual(rec.country_code, '--') + + def test_geo_cache_hit_avoids_http(self): + """A second row with the same recent IP copies from cache.""" + from unittest.mock import patch + Audit = self.env['fusion.login.audit'].sudo() + # Seed a "done" row from the same IP. + Audit.create({ + 'attempted_login': 'seed@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'done', + 'country_code': 'CA', + 'country_name': 'Canada', + 'city': 'Toronto', + 'geo_state': 'Ontario', + }) + target = Audit.create({ + 'attempted_login': 'hit@example.com', + 'result': 'success', + 'ip_address': '203.0.113.99', + 'geo_lookup_state': 'pending', + }) + + with patch( + 'odoo.addons.fusion_login_audit.models.fusion_login_audit.requests.get' + ) as mock_get: + Audit._fc_geo_enrich_pending(limit=10) + mock_get.assert_not_called() + + target.invalidate_recordset() + self.assertEqual(target.geo_lookup_state, 'done') + self.assertEqual(target.country_code, 'CA') + self.assertEqual(target.city, 'Toronto') + + def test_geo_internal_skipped(self): + """Rows with geo_lookup_state='internal' are not picked up.""" + Audit = self.env['fusion.login.audit'].sudo() + rec = Audit.create({ + 'attempted_login': 'cron@example.com', + 'result': 'success', + 'ip_address': 'internal', + 'geo_lookup_state': 'internal', + }) + # Should be a no-op. + Audit._fc_geo_enrich_pending(limit=10) + rec.invalidate_recordset() + self.assertEqual(rec.geo_lookup_state, 'internal') +``` + +- [ ] **Step 2: Run — expect 3 FAILs** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: 3 new `AttributeError` failures on `_fc_geo_enrich_pending`. + +- [ ] **Step 3: Implement the worker** + +Append to `models/fusion_login_audit.py` (top of file, add imports): + +```python +import ipaddress +import logging +import socket +from datetime import timedelta + +import requests + +_logger = logging.getLogger(__name__) +``` + +And inside the class: + +```python + _FC_PRIVATE_NETWORKS = ( + ipaddress.ip_network('10.0.0.0/8'), + ipaddress.ip_network('172.16.0.0/12'), + ipaddress.ip_network('192.168.0.0/16'), + ipaddress.ip_network('127.0.0.0/8'), + ipaddress.ip_network('::1/128'), + ipaddress.ip_network('fe80::/10'), + ) + + @api.model + def _fc_is_private_ip(self, ip): + if not ip or ip == 'internal': + return False # 'internal' is handled by its own state + try: + addr = ipaddress.ip_address(ip) + except ValueError: + return False + return any(addr in net for net in self._FC_PRIVATE_NETWORKS) + + @api.model + def _fc_geo_cache_hit(self, ip): + """Return a dict of geo fields if we've resolved this IP in the last + 30 days, else None.""" + if not ip: + return None + cutoff = fields.Datetime.now() - timedelta(days=30) + cached = self.sudo().search([ + ('ip_address', '=', ip), + ('geo_lookup_state', '=', 'done'), + ('event_time', '>=', cutoff), + ], limit=1, order='event_time desc') + if not cached: + return None + return { + 'country_code': cached.country_code, + 'country_name': cached.country_name, + 'city': cached.city, + 'geo_state': cached.geo_state, + 'ip_hostname': cached.ip_hostname, + } + + @api.model + def _fc_geo_reverse_dns(self, ip): + try: + socket.setdefaulttimeout(1.5) + host, _aliases, _ips = socket.gethostbyaddr(ip) + return (host or '')[:255] + except (socket.herror, socket.gaierror, OSError): + return '' + finally: + socket.setdefaulttimeout(None) + + @api.model + def _fc_geo_http_lookup(self, ip): + """Call ip-api.com. Returns (vals_dict, rate_limited_bool). + Falls back to ({}, False) on any error.""" + try: + resp = requests.get( + 'http://ip-api.com/json/' + ip, + params={'fields': 'status,country,countryCode,regionName,city'}, + timeout=3, + headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'}, + ) + rate_limited = resp.headers.get('X-Rl', '') == '0' + if resp.status_code != 200: + return ({}, rate_limited) + data = resp.json() or {} + if data.get('status') != 'success': + return ({}, rate_limited) + return ({ + 'country_code': (data.get('countryCode') or '')[:2], + 'country_name': (data.get('country') or '')[:64], + 'geo_state': (data.get('regionName') or '')[:64], + 'city': (data.get('city') or '')[:128], + }, rate_limited) + except (requests.RequestException, ValueError): + _logger.warning("fusion_login_audit: geo lookup failed for %s", + ip, exc_info=True) + return ({}, False) + + @api.model + def _fc_geo_enrich_pending(self, limit=100): + """Cron worker: process up to `limit` pending rows.""" + pending = self.sudo().search( + [('geo_lookup_state', '=', 'pending')], + order='event_time asc', limit=limit, + ) + if not pending: + return 0 + processed = 0 + for row in pending: + ip = row.ip_address + try: + if self._fc_is_private_ip(ip): + row.write({ + 'geo_lookup_state': 'private_ip', + 'country_code': '--', + 'country_name': 'Private network', + 'city': 'Private network', + }) + self.env.cr.commit() + processed += 1 + continue + + cached = self._fc_geo_cache_hit(ip) + if cached: + cached['geo_lookup_state'] = 'done' + row.write(cached) + self.env.cr.commit() + processed += 1 + continue + + hostname = self._fc_geo_reverse_dns(ip) if ip and ip != 'internal' else '' + vals, rate_limited = self._fc_geo_http_lookup(ip) if ip and ip != 'internal' else ({}, False) + if vals: + vals['ip_hostname'] = hostname + vals['geo_lookup_state'] = 'done' + row.write(vals) + else: + row.write({ + 'geo_lookup_state': 'failed', + 'ip_hostname': hostname, + }) + self.env.cr.commit() + processed += 1 + if rate_limited: + _logger.info("fusion_login_audit: ip-api rate limit " + "hit, stopping batch early") + break + except Exception: + _logger.exception( + "fusion_login_audit: geo enrich failed for row %s", row.id) + self.env.cr.rollback() + return processed +``` + +- [ ] **Step 4: Register the cron in `ir_cron_data.xml`** + +Append inside ``: + +```xml + + Fusion Login Audit: Geo Enrichment + + code + model._fc_geo_enrich_pending(limit=100) + 5 + minutes + -1 + + 10 + +``` + +- [ ] **Step 5: Run — expect all 26 tests PASS** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 26 tests`, `OK`. Confirm both crons are present: + +```bash +docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT a.name, c.interval_number, c.interval_type, c.active FROM ir_cron c JOIN ir_act_server a ON a.id=c.ir_actions_server_id WHERE a.name LIKE 'Fusion Login Audit%' ORDER BY a.name;" +``` + +Expected: 2 rows — "Fusion Login Audit: Geo Enrichment" (5 min) and "Fusion Login Audit: Retention GC" (1 day). + +- [ ] **Step 6: Commit** + +```powershell +cd K:\Github\Odoo-Modules +git add fusion_login_audit/ +git commit -m @' +feat(fusion_login_audit): async geo enrichment cron + +5-min cron processes up to 100 pending rows per pass: private IPs +short-circuit to state=private_ip; same-IP cache (30 days) avoids +duplicate ip-api.com calls; reverse DNS via socket with 1.5s timeout; +HTTP lookup routed through network_logger automatically. Rate-limit +header X-Rl honoured — batch breaks early when ip-api returns 0. +Tests cover the three non-HTTP paths (private, cache hit, internal-skip) +without touching the network. + +Co-Authored-By: Claude Opus 4.7 (1M context) +'@ +``` + +--- + +## Task 14: View-visibility security test (HttpCase) + +**Files:** +- Modify: `K:\Github\Odoo-Modules\fusion_login_audit\tests\test_security.py` + +The smart button and Login Activity tab are gated by `groups="base.group_system"` at the view level. Verify a non-admin user does not see them in the rendered view. + +- [ ] **Step 1: Append failing test** + +```python + def test_view_hides_button_and_tab_for_non_admin(self): + """A regular user fields_view_get on res.users does not include the + x_fc_login_audit_* fields (they live behind groups=base.group_system).""" + ResUsers = self.env['res.users'] + view = ResUsers.with_user(self.regular_user).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertNotIn('x_fc_login_audit_count', arch, + "Smart-button field must not leak into non-admin view") + self.assertNotIn('x_fc_login_audit_ids', arch, + "Login Activity tab must not leak into non-admin view") + + def test_view_shows_button_and_tab_for_admin(self): + """A Settings admin DOES see both nodes.""" + admin = self.env.ref('base.user_admin') + view = self.env['res.users'].with_user(admin).get_view( + view_id=self.env.ref('base.view_users_form').id, + view_type='form', + ) + arch = view['arch'] + self.assertIn('x_fc_login_audit_count', arch) + self.assertIn('x_fc_login_audit_ids', arch) +``` + +- [ ] **Step 2: Run — expect PASS already (the `groups="base.group_system"` attr is what makes this work)** + +```bash +docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init 2>&1 | grep -E "(FAIL|ERROR|OK|Ran)" | tail -10 +``` + +Expected: `Ran 28 tests`, `OK`. If either test fails, the `groups=` attribute on the smart-button `