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>
25 KiB
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.logrows are pruned by the daily_gc_user_logscron — only the most recent login per user survives. For GSA Accounting the entire history collapsed to a single row at2026-04-22 20:24 EDT./var/log/odooon the production VM is empty because Odoo is configured atlog_level=warnwith 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_loggermodule 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
- Durable audit trail of every password-authenticated login (success and failure) on
westin-v19. - Per-user visibility for Settings admins via a tab + smart button on
res.users. - Failure-burst alerting to admins on a configurable consecutive-failure threshold.
- Geo-enrichment of IPs out-of-band so authentication latency is unaffected.
- 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'(matchesfusion_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=Truecontext to avoid chatter noise.- The password value is never present in
valsand is hard-stripped from anycredentialdict before logging. A regression test asserts this.
Async geolocation cron (cron_geo_enrich)
Schedule: every 5 minutes, numbercall=-1, priority=10.
Worker logic:
- Select 100 oldest rows where
geo_lookup_state='pending'. - For each row:
- Private-IP shortcut: if
ip_addressis in10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8,::1, orfe80::/10→ setgeo_lookup_state='private_ip',country_code='--',city='Private network'. - Cache check: look for any prior row with the same
ip_addressandcountry_code IS NOT NULLandevent_time > now() - interval '30 days'. If found, copycountry_code/country_name/city/geo_state/ip_hostnamelocally; set statedone. No external call. - Reverse DNS:
socket.gethostbyaddr(ip)withsocket.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 throughnetwork_loggerautomatically. - On
status='success'→ fill fields, set statedone. - On HTTP error, timeout, or
status='fail'→ set statefailed(no retry).
- Private-IP shortcut: if
self.env.cr.commit()after each row so one bad IP cannot roll back the batch.- Rate limit defense: if the response header
X-Rlis'0', break early and leave remaining rows aspendingfor 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":Click → opens┌──────────────┐ │ 🔑 N Logins │ └──────────────┘fusion.login.auditlist 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.
- Header summary:
Standalone views for fusion.login.audit
- List view:
event_time,user_id(clickable),attempted_login(only whenuser_id IS NULL),resultbadge,ip_address,country_code,city,browser,device_type. Default sortevent_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_reasonstores a category, never the attempted password._fc_build_event_valsstripscredential['password']before any logging or row construction.- The
credentialdict is never persisted. - Regression test: no field on
fusion.login.auditever 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_systemwith a non-emptyemail. - Send path:
mail.mailqueue withauto_delete=Trueso 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)
- Successful login writes a row with
result='success'and resolveduser_id. - Bad password writes
result='failure'withfailure_reason='bad_password'and re-raisesAccessDenied. - Unknown user writes
result='failure'withfailure_reason='unknown_user',user_id=None, non-nullattempted_login. - No field on the written row contains the attempted password (regression).
- Geo cron: pending row gets enriched from local cache when same IP exists within 30 days (no HTTP call made).
- Retention cron: rows older than
retention_daysare deleted; newer survive. - Alert email: 5 failures in 15 min queues exactly one
mail.mail; a 6th failure within cooldown queues zero. databasefield is populated fromself.env.cr.dbname.- Audit-write exception inside
_update_last_logindoes not block the login.
tests/test_security.py (HttpCase)
- Non-admin user gets
AccessErroron directsearch(fusion.login.audit). - Non-admin sees the user form view without the smart button or "Login Activity" tab (XML node hidden by
groups). - Settings admin sees both.
Deployment notes
- Local install: copy module to
K:\Github\Odoo-Modules\fusion_login_audit\(bind-mounted intoodoo-modsdev-appcontainer). 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 (viaauto_sync.shor 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.pngtoK:\Github\Odoo-Modules\fusion_login_audit\static\description\icon.png. - Verify
proxy_mode = Truein/opt/odoo/odoo.confon odoo-westin before relying onip_addressaccuracy — otherwiseremote_addrwill 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:80is reachable from the odoo-westin VM (Tailscale/firewall) — if blocked,geo_lookup_statewill simply befailedand the rest of the module is unaffected.
Success criteria
- Logging in as any user creates exactly one
fusion.login.auditrow withresult='success'and the correct IP/UA. - Failed login attempts create exactly one row with
result='failure'and the correctfailure_reason. - Unknown-user attempts create a row with
user_id=Noneand the typed login string inattempted_login. - The smart button on
res.usersshows 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
emailset. - The geo cron populates
country_code,city,ip_hostnamefor 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.