From 01a85c475cec015ccd1befb15b1a9abda5267c15 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Tue, 26 May 2026 19:52:44 -0400 Subject: [PATCH] docs(spec): fusion_login_audit design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../2026-05-26-fusion-login-audit-design.md | 444 ++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md diff --git a/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md b/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md new file mode 100644 index 00000000..3ee9c00b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md @@ -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/` 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`.