Capture in the plan the Odoo 19 gotchas discovered during execution that the original plan template missed: - Test command requires --http-port=0 --gevent-port=0 (running container holds 8069). - Declarative models.Constraint / models.Index (T2). - res.users.groups_id renamed to group_ids (T3, T6). - ir.rule groups is additive not restrictive (T3). - mail.template inline-template ctx IS env.context (T11). - ir.cron has no numbercall field in 19 (T12). - registry.cursor() in tests is TestCursor; cr.commit() raises; use savepoints (T13). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2704 lines
104 KiB
Markdown
2704 lines
104 KiB
Markdown
# 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) is bind-mounted into the `odoo-modsdev-app` container at `/mnt/odoo-modules/fusion_login_audit/`. (Verify with `docker inspect odoo-modsdev-app --format '{{json .Mounts}}' | python -m json.tool` if in doubt.)
|
|
- **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.).
|
|
- **SQL constraints & indexes — Odoo 19 declarative form (project CLAUDE.md rule #9):**
|
|
Do **not** write `_sql_constraints = [(name, def, msg), ...]` or override `init(self)` with raw SQL. Odoo 19 silently drops both with only a warning. Use the declarative class attributes:
|
|
```python
|
|
_result_failure_reason_consistent = models.Constraint(
|
|
"CHECK (...)", "Failure rows must carry a failure_reason."
|
|
)
|
|
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
|
```
|
|
The attribute name (sans the leading underscore) becomes the SQL object's suffix: `{table}_{suffix}`. `models.Index` supports `DESC`, `WHERE`, and `USING ...`. Tasks that mention `_sql_constraints` or `init()` in this plan were written against the legacy pattern — translate to the declarative form when implementing.
|
|
- **Test command shape (use ephemeral ports — the dev container holds 8069):**
|
|
```bash
|
|
docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable --test-tags /fusion_login_audit -u fusion_login_audit --stop-after-init --http-port=0 --gevent-port=0 2>&1 | tail -60
|
|
```
|
|
Without `--http-port=0 --gevent-port=0` the second odoo process tries to bind 8069 and dies with `Address already in use`. Use `-i fusion_login_audit` only on the very first install; everything after T1 uses `-u` (update). Expected runtime: 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) <noreply@anthropic.com>
|
|
```
|
|
|
|
---
|
|
|
|
## 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="1">
|
|
|
|
<record id="rule_fusion_login_audit_admin_read" model="ir.rule">
|
|
<field name="name">fusion.login.audit: admin read only</field>
|
|
<field name="model_id" ref="model_fusion_login_audit"/>
|
|
<field name="domain_force">[(1, '=', 1)]</field>
|
|
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
|
<field name="perm_read" eval="True"/>
|
|
<field name="perm_write" eval="False"/>
|
|
<field name="perm_create" eval="False"/>
|
|
<field name="perm_unlink" eval="False"/>
|
|
</record>
|
|
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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'], '<no-request>')
|
|
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', '<no-request>') 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'] = '<no-request>'
|
|
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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<record id="view_users_form_inherit_login_audit" model="ir.ui.view">
|
|
<field name="name">res.users.form.inherit.fusion_login_audit</field>
|
|
<field name="model">res.users</field>
|
|
<field name="inherit_id" ref="base.view_users_form"/>
|
|
<field name="groups_id" eval="[(4, ref('base.group_system'))]"/>
|
|
<field name="arch" type="xml">
|
|
<!-- Smart button -->
|
|
<xpath expr="//div[@name='button_box']" position="inside">
|
|
<button name="action_fc_view_login_audit"
|
|
type="object"
|
|
class="oe_stat_button"
|
|
icon="fa-key"
|
|
groups="base.group_system">
|
|
<field name="x_fc_login_audit_count" widget="statinfo"
|
|
string="Logins"/>
|
|
</button>
|
|
</xpath>
|
|
|
|
<!-- Login Activity tab appended at the end of the notebook -->
|
|
<xpath expr="//notebook" position="inside">
|
|
<page string="Login Activity"
|
|
name="fc_login_activity"
|
|
groups="base.group_system">
|
|
<group>
|
|
<field name="x_fc_last_successful_login" readonly="1"/>
|
|
<field name="x_fc_last_login_ip" readonly="1"/>
|
|
</group>
|
|
<field name="x_fc_login_audit_ids" readonly="1"
|
|
context="{'create': False, 'edit': False, 'delete': False}">
|
|
<list create="false" edit="false" delete="false"
|
|
limit="30" default_order="event_time desc">
|
|
<field name="event_time"/>
|
|
<field name="result" decoration-success="result=='success'"
|
|
decoration-danger="result=='failure'"
|
|
widget="badge"/>
|
|
<field name="failure_reason"/>
|
|
<field name="ip_address"/>
|
|
<field name="country_code"/>
|
|
<field name="city"/>
|
|
<field name="browser"/>
|
|
<field name="os"/>
|
|
</list>
|
|
</field>
|
|
</page>
|
|
</xpath>
|
|
</field>
|
|
</record>
|
|
|
|
</odoo>
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<!-- List -->
|
|
<record id="view_fusion_login_audit_list" model="ir.ui.view">
|
|
<field name="name">fusion.login.audit.list</field>
|
|
<field name="model">fusion.login.audit</field>
|
|
<field name="arch" type="xml">
|
|
<list create="false" edit="false" delete="false"
|
|
default_order="event_time desc"
|
|
decoration-success="result=='success'"
|
|
decoration-danger="result=='failure'">
|
|
<field name="event_time"/>
|
|
<field name="user_id"/>
|
|
<field name="attempted_login"
|
|
column_invisible="parent.context.get('hide_attempted_login')"/>
|
|
<field name="result" widget="badge"/>
|
|
<field name="failure_reason"/>
|
|
<field name="ip_address"/>
|
|
<field name="country_code"/>
|
|
<field name="city"/>
|
|
<field name="browser"/>
|
|
<field name="device_type"/>
|
|
<field name="database" optional="hide"/>
|
|
</list>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Form (readonly) -->
|
|
<record id="view_fusion_login_audit_form" model="ir.ui.view">
|
|
<field name="name">fusion.login.audit.form</field>
|
|
<field name="model">fusion.login.audit</field>
|
|
<field name="arch" type="xml">
|
|
<form create="false" edit="false" delete="false">
|
|
<sheet>
|
|
<group>
|
|
<group string="Event">
|
|
<field name="event_time" readonly="1"/>
|
|
<field name="result" readonly="1" widget="badge"/>
|
|
<field name="failure_reason" readonly="1"/>
|
|
<field name="user_id" readonly="1"/>
|
|
<field name="attempted_login" readonly="1"/>
|
|
<field name="database" readonly="1"/>
|
|
</group>
|
|
<group string="Source">
|
|
<field name="ip_address" readonly="1"/>
|
|
<field name="ip_hostname" readonly="1"/>
|
|
<field name="country_code" readonly="1"/>
|
|
<field name="country_name" readonly="1"/>
|
|
<field name="geo_state" readonly="1"/>
|
|
<field name="city" readonly="1"/>
|
|
<field name="geo_lookup_state" readonly="1"/>
|
|
</group>
|
|
</group>
|
|
<group string="Client">
|
|
<field name="device_type" readonly="1"/>
|
|
<field name="browser" readonly="1"/>
|
|
<field name="os" readonly="1"/>
|
|
<field name="user_agent_raw" readonly="1"/>
|
|
</group>
|
|
</sheet>
|
|
</form>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Search -->
|
|
<record id="view_fusion_login_audit_search" model="ir.ui.view">
|
|
<field name="name">fusion.login.audit.search</field>
|
|
<field name="model">fusion.login.audit</field>
|
|
<field name="arch" type="xml">
|
|
<search>
|
|
<field name="attempted_login"/>
|
|
<field name="user_id"/>
|
|
<field name="ip_address"/>
|
|
<field name="country_code"/>
|
|
<filter name="filter_success" string="Successes"
|
|
domain="[('result','=','success')]"/>
|
|
<filter name="filter_failure" string="Failures"
|
|
domain="[('result','=','failure')]"/>
|
|
<separator/>
|
|
<filter name="filter_24h" string="Last 24 hours"
|
|
domain="[('event_time','>=', (context_today() - relativedelta(days=1)).strftime('%Y-%m-%d 00:00:00'))]"/>
|
|
<filter name="filter_7d" string="Last 7 days"
|
|
domain="[('event_time','>=', (context_today() - relativedelta(days=7)).strftime('%Y-%m-%d 00:00:00'))]"/>
|
|
<filter name="filter_30d" string="Last 30 days"
|
|
domain="[('event_time','>=', (context_today() - relativedelta(days=30)).strftime('%Y-%m-%d 00:00:00'))]"/>
|
|
<separator/>
|
|
<filter name="filter_unknown_user" string="Unknown users"
|
|
domain="[('user_id','=',False)]"/>
|
|
<group expand="0" string="Group By">
|
|
<filter name="group_user" string="User"
|
|
context="{'group_by': 'user_id'}"/>
|
|
<filter name="group_country" string="Country"
|
|
context="{'group_by': 'country_code'}"/>
|
|
<filter name="group_ip" string="IP"
|
|
context="{'group_by': 'ip_address'}"/>
|
|
</group>
|
|
</search>
|
|
</field>
|
|
</record>
|
|
|
|
<!-- Window actions -->
|
|
<record id="action_fusion_login_audit_all" model="ir.actions.act_window">
|
|
<field name="name">Login Events</field>
|
|
<field name="res_model">fusion.login.audit</field>
|
|
<field name="view_mode">list,form</field>
|
|
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
|
|
<field name="context">{}</field>
|
|
</record>
|
|
|
|
<record id="action_fusion_login_audit_failures_24h" model="ir.actions.act_window">
|
|
<field name="name">Failed Logins (24h)</field>
|
|
<field name="res_model">fusion.login.audit</field>
|
|
<field name="view_mode">list,form</field>
|
|
<field name="search_view_id" ref="view_fusion_login_audit_search"/>
|
|
<field name="context">{'search_default_filter_failure': 1, 'search_default_filter_24h': 1}</field>
|
|
</record>
|
|
|
|
</odoo>
|
|
```
|
|
|
|
`K:\Github\Odoo-Modules\fusion_login_audit\views\menus.xml`:
|
|
|
|
```xml
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<menuitem id="menu_fusion_login_audit_root"
|
|
name="Login Audit"
|
|
parent="base.menu_administration"
|
|
groups="base.group_system"
|
|
sequence="100"/>
|
|
|
|
<menuitem id="menu_fusion_login_audit_all"
|
|
name="Login Events"
|
|
parent="menu_fusion_login_audit_root"
|
|
action="action_fusion_login_audit_all"
|
|
groups="base.group_system"
|
|
sequence="10"/>
|
|
|
|
<menuitem id="menu_fusion_login_audit_failures"
|
|
name="Failed Logins (24h)"
|
|
parent="menu_fusion_login_audit_root"
|
|
action="action_fusion_login_audit_failures_24h"
|
|
groups="base.group_system"
|
|
sequence="20"/>
|
|
|
|
</odoo>
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
|
|
<record id="view_res_config_settings_form_login_audit" model="ir.ui.view">
|
|
<field name="name">res.config.settings.form.login.audit</field>
|
|
<field name="model">res.config.settings</field>
|
|
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
|
<field name="arch" type="xml">
|
|
<xpath expr="//block[@id='userManagement']" position="after">
|
|
<block title="Login Audit"
|
|
name="login_audit_block"
|
|
groups="base.group_system">
|
|
<setting id="login_audit_retention"
|
|
string="Retention (days)"
|
|
help="0 = keep forever">
|
|
<field name="x_fc_login_audit_retention_days"/>
|
|
</setting>
|
|
<setting id="login_audit_alert_enabled"
|
|
string="Send failed-login alerts"
|
|
help="Email Settings admins when consecutive failures cross the threshold">
|
|
<field name="x_fc_login_audit_alert_enabled"/>
|
|
</setting>
|
|
<setting id="login_audit_alert_threshold"
|
|
string="Alert threshold (failures)">
|
|
<field name="x_fc_login_audit_alert_threshold"/>
|
|
</setting>
|
|
<setting id="login_audit_alert_window"
|
|
string="Alert window (minutes)">
|
|
<field name="x_fc_login_audit_alert_window_min"/>
|
|
</setting>
|
|
</block>
|
|
</xpath>
|
|
</field>
|
|
</record>
|
|
|
|
</odoo>
|
|
```
|
|
|
|
> If `//block[@id='userManagement']` doesn't exist in this Odoo 19 build, fall back to `<xpath expr="//div[hasclass('settings')]" position="inside">` and wrap in a `<block>...</block>` 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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="1">
|
|
|
|
<record id="mail_template_failure_burst" model="mail.template">
|
|
<field name="name">Fusion Login Audit — Failure Burst Alert</field>
|
|
<field name="model_id" ref="base.model_res_users"/>
|
|
<field name="subject">[Login Audit] Failed login attempts for {{ ctx.get('attempted_login') }}</field>
|
|
<field name="body_html" type="html">
|
|
<div>
|
|
<p>The login audit detected
|
|
<strong t-out="ctx.get('failure_count')"/> failed login attempt(s)
|
|
in the last <t t-out="ctx.get('window_min')"/> minute(s) for
|
|
<strong t-out="ctx.get('attempted_login')"/>.</p>
|
|
<p>Most recent attempts:</p>
|
|
<table border="1" cellpadding="4" cellspacing="0"
|
|
style="border-collapse: collapse; font-family: sans-serif; font-size: 12px;">
|
|
<thead style="background: #f3f4f6;">
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>IP</th>
|
|
<th>Country</th>
|
|
<th>Browser</th>
|
|
<th>OS</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr t-foreach="ctx.get('rows', [])" t-as="row">
|
|
<td t-out="row['event_time']"/>
|
|
<td t-out="row['ip_address']"/>
|
|
<td t-out="row.get('country_code') or ''"/>
|
|
<td t-out="row.get('browser') or ''"/>
|
|
<td t-out="row.get('os') or ''"/>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p style="color: #6b7280; font-size: 11px;">
|
|
Sent by Fusion Login Audit. Tune the threshold and window in
|
|
Settings → General Settings → Login Audit.
|
|
</p>
|
|
</div>
|
|
</field>
|
|
</record>
|
|
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
- [ ] **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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<odoo>
|
|
<data noupdate="1">
|
|
|
|
<record id="cron_retention_gc" model="ir.cron">
|
|
<field name="name">Fusion Login Audit: Retention GC</field>
|
|
<field name="model_id" ref="model_fusion_login_audit"/>
|
|
<field name="state">code</field>
|
|
<field name="code">model._fc_retention_gc()</field>
|
|
<field name="interval_number">1</field>
|
|
<field name="interval_type">days</field>
|
|
<field name="numbercall">-1</field>
|
|
<field name="active" eval="True"/>
|
|
<field name="nextcall"
|
|
eval="(DateTime.now() + relativedelta(hour=3, minute=0, second=0) + relativedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
|
</record>
|
|
|
|
</data>
|
|
</odoo>
|
|
```
|
|
|
|
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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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 `<data noupdate="1">`:
|
|
|
|
```xml
|
|
<record id="cron_geo_enrich" model="ir.cron">
|
|
<field name="name">Fusion Login Audit: Geo Enrichment</field>
|
|
<field name="model_id" ref="model_fusion_login_audit"/>
|
|
<field name="state">code</field>
|
|
<field name="code">model._fc_geo_enrich_pending(limit=100)</field>
|
|
<field name="interval_number">5</field>
|
|
<field name="interval_type">minutes</field>
|
|
<field name="numbercall">-1</field>
|
|
<field name="active" eval="True"/>
|
|
<field name="priority">10</field>
|
|
</record>
|
|
```
|
|
|
|
- [ ] **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) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## 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 `<button>` or the `<page>` is missing — add it.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```powershell
|
|
cd K:\Github\Odoo-Modules
|
|
git add fusion_login_audit/
|
|
git commit -m @'
|
|
test(fusion_login_audit): view visibility checks for admin vs non-admin
|
|
|
|
Asserts the smart-button and Login Activity tab nodes are stripped
|
|
from get_view() for non-admin users and present for Settings admins.
|
|
This nails down the contract behind the groups="base.group_system"
|
|
attributes on the res.users form inheritance.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Manual smoke + production readiness
|
|
|
|
**Files:** none (verification only)
|
|
|
|
- [ ] **Step 1: Final test run from scratch**
|
|
|
|
```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 | tail -20
|
|
```
|
|
|
|
Expected: `Ran 28 tests`, `OK`, exit 0.
|
|
|
|
- [ ] **Step 2: Manual login smoke test in the browser**
|
|
|
|
Open `http://localhost:8082` → log out → log back in as `admin`. Then in another browser/window, try to log in as `nobody-here@example.com` with wrong password. Then check:
|
|
|
|
```bash
|
|
docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT result, attempted_login, ip_address, browser, geo_lookup_state, event_time FROM fusion_login_audit ORDER BY event_time DESC LIMIT 10;"
|
|
```
|
|
|
|
Expected: at least 3 rows — one success for `admin`, one failure with `failure_reason='unknown_user'`, IP populated, `geo_lookup_state='pending'` (or `private_ip` if 127.0.0.1).
|
|
|
|
- [ ] **Step 3: Manually trigger the geo cron**
|
|
|
|
In the Odoo UI: Settings → Technical → Scheduled Actions → "Fusion Login Audit: Geo Enrichment" → "Run Manually". Then:
|
|
|
|
```bash
|
|
docker exec odoo-modsdev-db psql -U odoo -d fusion-dev -c "SELECT ip_address, geo_lookup_state, country_code, city FROM fusion_login_audit WHERE geo_lookup_state IN ('done','private_ip','failed') ORDER BY event_time DESC LIMIT 5;"
|
|
```
|
|
|
|
Expected: previously-`pending` rows are now `done` (if the IP was public) or `private_ip` (if loopback/LAN).
|
|
|
|
- [ ] **Step 4: Verify the smart button + tab in the UI**
|
|
|
|
Browse to a user record as admin → confirm "🔑 N Logins" smart button is visible and clickable → confirm "Login Activity" tab is the last tab and shows the recent rows.
|
|
|
|
Log out, log in as a non-admin user (e.g. demo user from base), browse to the same user record (or your own profile from the user menu) → confirm neither the smart button nor the Login Activity tab are visible.
|
|
|
|
- [ ] **Step 5: Production deployment notes (do NOT push without explicit user request)**
|
|
|
|
Document the production deploy steps in a follow-up comment on the PR or in `docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md`:
|
|
|
|
```bash
|
|
# On odoo-westin VM:
|
|
ssh odoo-westin
|
|
cd /opt/odoo/custom-addons
|
|
# pull or rsync fusion_login_audit/ into place
|
|
docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init
|
|
# verify
|
|
docker exec odoo-dev-db psql -U odoo -d westin-v19 -c "SELECT state FROM ir_module_module WHERE name='fusion_login_audit';"
|
|
```
|
|
|
|
Also confirm `proxy_mode = True` is set in `/opt/odoo/odoo.conf` so `ip_address` reflects the real client and not the reverse-proxy IP.
|
|
|
|
- [ ] **Step 6: Final commit (release tag)**
|
|
|
|
```powershell
|
|
cd K:\Github\Odoo-Modules
|
|
git log --oneline feat/fusion-login-audit ^main
|
|
# After confirming the commit list is what you want:
|
|
git commit --allow-empty -m @'
|
|
chore(fusion_login_audit): release 19.0.1.0.0
|
|
|
|
Module is feature-complete per
|
|
docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md:
|
|
- _update_last_login, _check_credentials, _login hooks
|
|
- Smart button + Login Activity tab on res.users (admin-gated)
|
|
- Standalone list/form/search views + Settings → Technical menus
|
|
- res.config.settings section with retention/alert knobs
|
|
- Mail template + cooldown for failure-burst alerts
|
|
- Daily retention GC cron + 5-min async geo enrichment cron
|
|
- 28 tests passing
|
|
|
|
Out of scope for v1 (documented in spec): API-key auth, OAuth/SSO,
|
|
per-user self-service view.
|
|
|
|
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
'@
|
|
```
|
|
|
|
---
|
|
|
|
## Self-review log
|
|
|
|
Run on date: 2026-05-26.
|
|
|
|
**Spec coverage** — every section of the spec maps to a task:
|
|
|
|
| Spec section | Implemented in |
|
|
|---|---|
|
|
| Module skeleton + manifest + icon | Task 1 |
|
|
| `fusion.login.audit` model + indexes | Task 2 |
|
|
| `res.users` `x_fc_*` fields | Task 8 |
|
|
| `res.config.settings` `x_fc_*` fields | Task 10 |
|
|
| Security (ACL + record rule) | Tasks 2 + 3 |
|
|
| Capture flow — success | Task 5 |
|
|
| Capture flow — known-user failure | Task 6 |
|
|
| Capture flow — unknown-user failure | Task 7 |
|
|
| Capture helper `_fc_build_event_vals` | Task 4 |
|
|
| Geo cron | Task 13 |
|
|
| UI surfaces (smart button + tab) | Task 8 |
|
|
| Standalone views + menus | Task 9 |
|
|
| Settings page section | Task 10 |
|
|
| Failure-burst alert | Task 11 |
|
|
| Retention | Task 12 |
|
|
| Tests | Tasks 2/3/5/6/7/8/10/11/12/13/14 |
|
|
|
|
**Placeholder scan:** none — every step contains the actual code, command, or expected output.
|
|
|
|
**Type/name consistency:** verified — `x_fc_login_audit_count` matches between the field definition (Task 8), the view (Task 8), and the test (Task 8). `_fc_record_login_event` matches across success (Task 5), known-user failure (Task 6), and unknown-user failure helper (Task 7). `_fc_recent_failure_count` / `_fc_send_failure_alert` / `_fc_alert_threshold` consistent across Task 11. `_fc_retention_gc` (model method, Task 12) and `_fc_geo_enrich_pending` (Task 13) match their cron `code` strings.
|
|
|
|
---
|
|
|
|
## Out-of-scope (carry-over to a follow-up plan)
|
|
|
|
- API-key authentication path (`credential['type'] == 'apikey'`)
|
|
- OAuth / SSO providers
|
|
- Per-user self-service "view my own logins" — would need a separate, narrower record rule
|
|
- Dashboard / kanban summary of attack patterns
|
|
- Auto-blocklist of IPs after N failures (would need to interact with `proxy_mode` and the reverse proxy, not just Odoo)
|