Files
Odoo-Modules/docs/superpowers/specs/2026-05-26-fusion-login-audit-design.md
gsinghpal 01a85c475c 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>
2026-05-27 09:03:58 -04:00

25 KiB
Raw Blame History

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)

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)

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)

@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:

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:

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.