docs(spec): fusion_login_audit design

Durable login audit for Odoo 19 (westin-v19). Captures successful and
failed authentications via _update_last_login / _check_credentials /
_login overrides, surfaces history on res.users as a smart button +
"Login Activity" tab (admins-only), async geo-enriches IPs via ip-api.com
through network_logger, 365-day retention with daily GC cron, and
emails Settings admins on N consecutive failures for the same login
within a configurable window.

Motivation: a spot audit of GSA Accounting (uid 63) showed Odoo's
res_users_log keeps only one row per user (rest is GC'd), /var/log/odoo
is empty (warn-level stdout logging), and the container json log
rotates within days — leaving no durable login trail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-26 19:52:44 -04:00
parent 43b2edcbb5
commit 01a85c475c

View File

@@ -0,0 +1,444 @@
# Fusion Login Audit — Design Spec
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-26
**Author:** Brainstormed with the user (Gurpreet) for the Westin Healthcare Odoo 19 deployment
**Target module path:** `K:\Github\Odoo-Modules\fusion_login_audit\`
**Production deploy target:** `/opt/odoo/custom-addons/fusion_login_audit/` on `odoo-westin` (VM 101, worker1, 192.168.1.40)
**Production DB:** `westin-v19` (Odoo 19, PostgreSQL)
## Background and motivation
A spot audit of user `info@gsafinancialconsulting.com` ("GSA Accounting", uid 63) revealed Odoo's built-in login tracking is effectively unusable for compliance:
- `res.users.log` rows are pruned by the daily `_gc_user_logs` cron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at `2026-04-22 20:24 EDT`.
- `/var/log/odoo` on the production VM is empty because Odoo is configured at `log_level=warn` with stdout-only logging; INFO-level auth lines aren't captured anywhere.
- The container's json log is 444 KB and rotates frequently — nothing about the user remains.
- The existing `network_logger` module records outbound HTTP traffic from Odoo (uid=1 always), not user activity.
Result: today there is **no durable record** of who logged in, when, from where, or how often. A user with `base.group_system` + Technical Features and no 2FA — like GSA Accounting — could be active for months without any reconstructable trail.
This module closes that gap with a dedicated audit table that survives Odoo's GC, captures successful and failed authentications, surfaces results in the user form, and alerts admins on suspicious failure bursts.
## Goals
1. **Durable audit trail** of every password-authenticated login (success and failure) on `westin-v19`.
2. **Per-user visibility** for Settings admins via a tab + smart button on `res.users`.
3. **Failure-burst alerting** to admins on a configurable consecutive-failure threshold.
4. **Geo-enrichment** of IPs out-of-band so authentication latency is unaffected.
5. **Zero risk to the auth path** — an audit-write failure must never block a real login.
## Non-goals (v1)
- Logging every HTTP request / page view (explicitly de-scoped during brainstorming).
- Logging session resume events from auth cookies.
- API-key authentication (`credential['type'] == 'apikey'`) — bypasses `_check_credentials`. Documented as a known gap; addressable in a follow-up.
- OAuth / SSO logins — no OAuth provider configured on westin-v19.
- Self-service "view my own login activity" for end users — visibility is admin-only.
- Auto-disabling users on failed logins — flagged as a self-service DoS vector during brainstorming.
## Architecture overview
```
┌─────────────────────────────────────────────────────────────────────┐
│ Odoo authentication path │
│ │
│ /web/login → res.users._login() → res.users._check_credentials() │
│ ↓ │
│ (on success) │
│ ↓ │
│ res.users._update_last_login() │
│ ↓ │
│ ┌────────────────────┴────────────────────┐ │
│ │ │ │
│ ▼ ▼ │
│ fusion.login.audit (sudo create) Odoo's existing res_users_log │
│ result='success' + IP + UA │
│ │
│ (on AccessDenied) │
│ ↓ │
│ fusion.login.audit (sudo create) │
│ result='failure' + failure_reason + attempted_login │
│ ↓ │
│ _fc_recent_failure_count() >= threshold? │
│ ↓ yes │
│ _fc_send_failure_alert() → mail.mail to base.group_system │
└──────────────────────────────────┬──────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
cron: cron_geo_enrich cron: cron_retention_gc UI surfaces:
every 5 min daily 03:00 UTC - smart button on res.users
- reverse DNS - delete rows older than - "Login Activity" tab
- ip-api.com lookup x_fc_login_audit_ - Settings → Technical →
- 30-day local cache retention_days Login Audit menus
- Settings page section
```
The auth-path hooks are synchronous (must run inside the request). Geolocation, alerting, and retention are out-of-band so they cannot affect login latency.
## Module skeleton
```
fusion_login_audit/
├── __manifest__.py
├── __init__.py
├── models/
│ ├── __init__.py
│ ├── res_users.py # extends res.users with capture hooks + computed fields + smart-button action
│ ├── fusion_login_audit.py # the new audit record model
│ └── res_config_settings.py # alert threshold + window + retention settings
├── data/
│ ├── ir_cron_data.xml # cron_geo_enrich + cron_retention_gc
│ └── mail_template_data.xml # failed-login alert template
├── security/
│ ├── security.xml # record rule: read for base.group_system only
│ └── ir.model.access.csv
├── views/
│ ├── fusion_login_audit_views.xml # list / form / kanban / search
│ ├── res_users_views.xml # tab + smart button
│ ├── res_config_settings_views.xml # Settings section
│ └── menus.xml # Settings → Technical → Login Audit
├── tests/
│ ├── __init__.py
│ ├── test_login_audit.py
│ └── test_security.py
└── static/
└── description/
└── icon.png # copied from C:\Users\gsing\Downloads\fusion logs.png
```
**Manifest highlights**
- `version='19.0.1.0.0'` (project naming convention)
- `license='OPL-1'` (matches `fusion_accounts`)
- `depends=['base', 'mail']`
- `category='Tools'`
- `application=False` (it's a technical addon, not a top-level app)
**Dependencies (Python):** none new. Uses the `user_agents` library already shipped with Odoo. Geolocation calls `http://ip-api.com/json/<ip>` via the standard `requests` library (no API key required, 45 req/min free tier).
**Field naming:** new fields on existing models (`res.users`, `res.config.settings`) use the `x_fc_*` prefix per project CLAUDE.md. The new `fusion.login.audit` model uses unprefixed field names.
## Data model
### `fusion.login.audit` (new model, table `fusion_login_audit`)
| Field | Type | Notes |
|---|---|---|
| `user_id` | Many2one(`res.users`, `ondelete='set null'`) | Null if attempted login didn't match any user |
| `attempted_login` | Char(255), indexed | Always set — even on unknown-user failures |
| `result` | Selection(`success`, `failure`) | Indexed |
| `failure_reason` | Selection(`bad_password`, `unknown_user`, `disabled_user`, `2fa_failed`, `other`) | Null on success |
| `event_time` | Datetime, indexed, default `fields.Datetime.now()` | UTC; displayed in user TZ via standard widget |
| `ip_address` | Char(45) | IPv6-safe length |
| `ip_hostname` | Char(255) | Reverse DNS, populated by geo cron |
| `country_code` | Char(2), indexed | ISO-3166-1 alpha-2; null until cron runs |
| `country_name` | Char(64) | |
| `city` | Char(128) | |
| `geo_state` | Char(64) | Region/state name |
| `geo_lookup_state` | Selection(`pending`, `done`, `private_ip`, `internal`, `failed`) | Drives the geo cron worklist; `internal` = no HTTP request was attached |
| `user_agent_raw` | Char(512) | The full UA header |
| `browser` | Char(64) | e.g. "Chrome 140" — parsed |
| `os` | Char(64) | e.g. "Windows 11" — parsed |
| `device_type` | Selection(`desktop`, `mobile`, `tablet`, `bot`, `unknown`) | From `user_agents` |
| `database` | Char(64) | Multi-DB safety — which DB was logged into |
**Indexes (in addition to the column-level `indexed=True`):**
- `(user_id, event_time DESC)` — per-user history
- `(attempted_login, event_time DESC)` — failure-burst detection by login string
- `(geo_lookup_state, event_time)` — cron worklist
**No `_inherit = ['mail.thread']`** — audit rows are append-only and should not have chatter.
### `res.users` additions (per CLAUDE.md `x_fc_*` convention)
| Field | Type | Notes |
|---|---|---|
| `x_fc_login_audit_ids` | One2many(`fusion.login.audit`, `user_id`) | Backs the tab + smart-button count |
| `x_fc_login_audit_count` | Integer, compute, store=False | Smart-button label |
| `x_fc_last_successful_login` | Datetime, compute, store=True | Indexed; cheap "last seen" lookup |
| `x_fc_last_login_ip` | Char(45), compute, store=True | Surfaces last source IP in the form header |
The `store=True` computes are triggered by the create on `fusion.login.audit` (via `@api.depends('x_fc_login_audit_ids.event_time', 'x_fc_login_audit_ids.result')`).
### `res.config.settings` additions
Booleans / integers only (per CLAUDE.md — no Date fields on settings):
| Field | Default | Notes |
|---|---|---|
| `x_fc_login_audit_retention_days` | 365 | Retention GC cron honors this; 0 = keep forever |
| `x_fc_login_audit_alert_threshold` | 5 | Consecutive failures before alert |
| `x_fc_login_audit_alert_window_min` | 15 | Time window in minutes for "consecutive" |
| `x_fc_login_audit_alert_enabled` | True | Master kill-switch for alert emails |
Each is backed by an `ir.config_parameter` (`fusion_login_audit.retention_days`, etc.) so changes from the Settings page persist.
### Multi-company
`fusion.login.audit` is intentionally **company-agnostic**. Logins happen before any company context is established; synthesizing one would either break the unknown-user case or require a "system company" placeholder. Settings admins see all rows globally.
## Capture flow
### Successful login (`_update_last_login`)
```python
def _update_last_login(self):
result = super()._update_last_login()
try:
self._fc_record_login_event(result='success')
except Exception:
_logger.exception("fusion_login_audit: failed to record success row for %s", self.login)
return result
```
Called by Odoo only after the credential check has passed. Super() runs first so Odoo's own bookkeeping is unaffected.
### Failed login on known user (`_check_credentials`)
```python
def _check_credentials(self, credential, env):
try:
return super()._check_credentials(credential, env)
except AccessDenied:
try:
self._fc_record_login_failure(credential, reason='bad_password')
if self._fc_recent_failure_count(credential) >= self._fc_alert_threshold():
self._fc_send_failure_alert(credential)
except Exception:
_logger.exception("fusion_login_audit: failed to record/alert failure")
raise
```
TOTP failures (from `auth_totp`) also raise `AccessDenied` and are caught here. Distinguish via `credential.get('type') == 'totp'` to set `failure_reason='2fa_failed'`.
### Failed login on unknown user (`_login` classmethod)
```python
@classmethod
def _login(cls, db, credential, user_agent_env):
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")
raise
```
Without this override, unknown-user attempts never reach `_check_credentials` and would silently disappear from the audit. The classmethod sets `user_id=None` and stores the attempted login string.
### Context extraction (`_fc_build_event_vals`)
Single helper shared by all three paths:
```python
def _fc_build_event_vals(self, result, attempted_login, failure_reason=None):
from odoo.http import request
vals = {
'attempted_login': attempted_login,
'result': result,
'failure_reason': failure_reason,
'event_time': fields.Datetime.now(),
'database': self.env.cr.dbname,
'geo_lookup_state': 'pending',
}
if request and request.httprequest:
vals['ip_address'] = request.httprequest.remote_addr # respects proxy_mode
ua_str = request.httprequest.user_agent.string or ''
vals['user_agent_raw'] = ua_str[:512]
from user_agents import parse as ua_parse
ua = ua_parse(ua_str)
vals['browser'] = f"{ua.browser.family} {ua.browser.version_string}"[:64]
vals['os'] = f"{ua.os.family} {ua.os.version_string}"[:64]
vals['device_type'] = (
'mobile' if ua.is_mobile else
'tablet' if ua.is_tablet else
'bot' if ua.is_bot else
'desktop' if ua.is_pc else 'unknown'
)
else:
vals['ip_address'] = 'internal'
vals['user_agent_raw'] = '<no-request>'
vals['geo_lookup_state'] = 'internal' # distinct from private_ip; cron skips both
return vals
```
### Write semantics
- All writes use `self.env['fusion.login.audit'].sudo().create(vals)` — low-privilege users can still generate their own audit rows despite the read-only record rule.
- `mail_create_nolog=True` context to avoid chatter noise.
- The password value is **never** present in `vals` and is hard-stripped from any `credential` dict before logging. A regression test asserts this.
## Async geolocation cron (`cron_geo_enrich`)
**Schedule:** every 5 minutes, `numbercall=-1`, `priority=10`.
**Worker logic:**
1. Select 100 oldest rows where `geo_lookup_state='pending'`.
2. For each row:
- **Private-IP shortcut:** if `ip_address` is in `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `::1`, or `fe80::/10` → set `geo_lookup_state='private_ip'`, `country_code='--'`, `city='Private network'`.
- **Cache check:** look for any prior row with the same `ip_address` and `country_code IS NOT NULL` and `event_time > now() - interval '30 days'`. If found, copy `country_code` / `country_name` / `city` / `geo_state` / `ip_hostname` locally; set state `done`. No external call.
- **Reverse DNS:** `socket.gethostbyaddr(ip)` with `socket.setdefaulttimeout(1.5)`.
- **HTTP lookup:** `requests.get('http://ip-api.com/json/' + ip, params={'fields': 'status,country,countryCode,regionName,city'}, timeout=3, headers={'User-Agent': 'Odoo-FusionLoginAudit/19.0'})`. The call passes through `network_logger` automatically.
- On `status='success'` → fill fields, set state `done`.
- On HTTP error, timeout, or `status='fail'` → set state `failed` (no retry).
3. `self.env.cr.commit()` after each row so one bad IP cannot roll back the batch.
4. **Rate limit defense:** if the response header `X-Rl` is `'0'`, break early and leave remaining rows as `pending` for the next run.
**Privacy:** the only outbound data is the IP itself. No user identifiers, no Odoo URL, no headers beyond `User-Agent: Odoo-FusionLoginAudit/19.0`. All outbound calls are auditable in `network_logger`.
## UI surfaces
### `res.users` form view
- **Smart button** in the button box, gated `groups="base.group_system"`:
```
┌──────────────┐
│ 🔑 N Logins │
└──────────────┘
```
Click → opens `fusion.login.audit` list view filtered to this user (`domain=[('user_id', '=', active_id)]`).
- **New tab "Login Activity"** appended after existing tabs, gated `groups="base.group_system"`:
- Header summary: `x_fc_last_successful_login`, `x_fc_last_login_ip` (readonly).
- Embedded one2many tree on `x_fc_login_audit_ids`, `limit="30"`, columns: `event_time`, `result` (colored badge), `ip_address`, `country_code` (with flag emoji display), `browser`, `os`, `failure_reason`.
- Tree is `create="false" edit="false" delete="false"`.
- "View full history →" button below the tree, same action as the smart button.
### Standalone views for `fusion.login.audit`
- **List view:** `event_time`, `user_id` (clickable), `attempted_login` (only when `user_id IS NULL`), `result` badge, `ip_address`, `country_code`, `city`, `browser`, `device_type`. Default sort `event_time DESC`.
- **Search view:** filters for "Successes", "Failures", "Last 24h", "Last 7d", "Last 30d", "Unknown users (no user_id)"; group-by IP / country / user.
- **Form view:** readonly; collapsible "Raw" section for `user_agent_raw`, `ip_hostname`, `database`, `geo_lookup_state`.
- **Kanban view:** grouped by `result`, color-coded green/red.
### Menus
Under **Settings → Technical → Login Audit**:
- "Login Events" → default list view
- "Failed Logins (24h)" → list view with default `[('result', '=', 'failure'), ('event_time', '>=', context_today() - 1)]`
### Settings page
New "Login Audit" section in **Settings → General Settings** (gated `groups="base.group_system"`):
- "Retention period (days)" — integer, help: "0 = keep forever"
- "Alert threshold" — integer
- "Alert window (minutes)" — integer
- "Send failed-login alerts" — boolean
## Security
### Group
No new group created. Read is bound to existing `base.group_system`. Rationale: brainstorming decision was "Settings admins only" — reusing the existing group avoids an extra checkbox to manage.
### Model access (`ir.model.access.csv`)
| Group | Read | Write | Create | Unlink |
|---|---|---|---|---|
| `base.group_system` | ✓ | ✗ | ✗ | ✗ |
**No write/create/unlink for any group via the UI.** Audit rows are only written via `sudo()` from inside the auth hooks. An audit log admins can mutate is not an audit log.
### Record rule
Single global rule on `fusion.login.audit`: read for `base.group_system` only. The user-form one2many is additionally gated at the view level via `groups="base.group_system"` (not via a more permissive record rule) so non-admins have no read path even if they craft a custom view.
### Field-level
- `failure_reason` stores a category, never the attempted password.
- `_fc_build_event_vals` strips `credential['password']` before any logging or row construction.
- The `credential` dict is never persisted.
- Regression test: no field on `fusion.login.audit` ever contains a known-test-password string.
## Retention
**Cron `cron_retention_gc`** — daily at 03:00 UTC, `numbercall=-1`:
```python
days = int(self.env['ir.config_parameter'].sudo().get_param(
'fusion_login_audit.retention_days', 365))
if days > 0:
cutoff = fields.Datetime.now() - timedelta(days=days)
self.env['fusion.login.audit'].sudo().search([
('event_time', '<', cutoff)
]).unlink()
```
Uses `unlink()` rather than raw `DELETE` so any ORM side effects fire. Expected DB load on `westin-v19`: 27 users × ~2 logins/day × 365 days ≈ 20k rows steady state — trivial for Postgres.
## Failed-login alert
**Mail template** in `data/mail_template_data.xml`:
- **Subject:** `[Login Audit] {threshold} failed login attempts for {attempted_login}`
- **Body:** simple HTML table of the last N failure rows for that `attempted_login` — timestamp, IP, country, user-agent summary.
- **Recipients:** all users in `base.group_system` with a non-empty `email`.
- **Send path:** `mail.mail` queue with `auto_delete=True` so the auth response isn't blocked.
**Cooldown:** 60 min per `attempted_login`, enforced via an `ir.config_parameter` keyed by `fusion_login_audit.last_alert:{attempted_login}` storing the last-send timestamp. Prevents a sustained attack from flooding admin inboxes.
**Kill-switch:** if `x_fc_login_audit_alert_enabled = False`, no alerts are sent regardless of threshold.
## Edge cases
| Case | Behavior |
|---|---|
| `request` is None (XML-RPC, internal auth from cron) | Row written with `ip_address='internal'`, `user_agent_raw='<no-request>'`, `geo_lookup_state='internal'` (cron skips) |
| Audit insert errors on a hot DB | Login still succeeds — every auth-path hook is wrapped in `try/except Exception: _logger.exception(...)` |
| User deleted while audit rows remain | `ondelete='set null'` preserves history; `attempted_login` keeps the readable identifier |
| Password reset / `auth_signup` | The reset itself generates no login event; the subsequent login does — matches expectation |
| API key authentication | **Out of scope v1** (bypasses `_check_credentials`); documented |
| OAuth / SSO | Out of scope v1; no provider configured on westin-v19 |
| Portal user (`share=True`) | Logged the same way; smart button remains admin-visible |
| Two requests racing on the same private IP | Each writes its own row; geo cache is best-effort, not transactional |
| `proxy_mode = False` in `odoo.conf` | `remote_addr` will be the reverse-proxy IP — known limitation, fixable by setting `proxy_mode = True` (out of scope) |
## Testing
### `tests/test_login_audit.py` (TransactionCase)
1. Successful login writes a row with `result='success'` and resolved `user_id`.
2. Bad password writes `result='failure'` with `failure_reason='bad_password'` and re-raises `AccessDenied`.
3. Unknown user writes `result='failure'` with `failure_reason='unknown_user'`, `user_id=None`, non-null `attempted_login`.
4. No field on the written row contains the attempted password (regression).
5. Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made).
6. Retention cron: rows older than `retention_days` are deleted; newer survive.
7. Alert email: 5 failures in 15 min queues exactly one `mail.mail`; a 6th failure within cooldown queues zero.
8. `database` field is populated from `self.env.cr.dbname`.
9. Audit-write exception inside `_update_last_login` does not block the login.
### `tests/test_security.py` (HttpCase)
1. Non-admin user gets `AccessError` on direct `search(fusion.login.audit)`.
2. Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by `groups`).
3. Settings admin sees both.
## Deployment notes
- **Local install:** copy module to `K:\Github\Odoo-Modules\fusion_login_audit\` (bind-mounted into `odoo-modsdev-app` container). Update via:
```
docker exec odoo-modsdev-app odoo -d fusion-dev -i fusion_login_audit --stop-after-init
```
- **Production install:** sync to `/opt/odoo/custom-addons/fusion_login_audit/` on odoo-westin (via `auto_sync.sh` or git pull on the VM). Update via:
```
ssh odoo-westin "docker exec odoo-dev-app odoo -d westin-v19 -i fusion_login_audit --stop-after-init"
```
- **Icon:** copy `C:\Users\gsing\Downloads\fusion logs.png` to `K:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png`.
- **Verify `proxy_mode = True`** in `/opt/odoo/odoo.conf` on odoo-westin before relying on `ip_address` accuracy — otherwise `remote_addr` will be the reverse-proxy IP rather than the real client. Confirmed out of scope for this module, but flag for the operator.
- **Verify outbound to `ip-api.com:80`** is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked, `geo_lookup_state` will simply be `failed` and the rest of the module is unaffected.
## Success criteria
- Logging in as any user creates exactly one `fusion.login.audit` row with `result='success'` and the correct IP/UA.
- Failed login attempts create exactly one row with `result='failure'` and the correct `failure_reason`.
- Unknown-user attempts create a row with `user_id=None` and the typed login string in `attempted_login`.
- The smart button on `res.users` shows the lifetime count and opens the filtered list.
- The "Login Activity" tab shows the last 30 events with correct color coding.
- After 5 failures from the same login string within 15 minutes, exactly one alert email arrives in the inbox of every Settings admin with an `email` set.
- The geo cron populates `country_code`, `city`, `ip_hostname` for public IPs within 10 minutes of the login.
- The retention cron, set to 1 day for a test, deletes rows older than 24 hours and leaves newer ones.
- All tests pass: `docker exec odoo-modsdev-app odoo -d fusion-dev --test-enable -i fusion_login_audit --stop-after-init`.