# 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/` 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'] = '' 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=''`, `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`.