- All 16 columns per spec (user, attempted_login, result, failure_reason, event_time, ip/geo fields, user_agent triple, device_type, database). - Check constraint binds failure_reason presence to result value. - Three composite indexes (user+time, login+time, geo_state+time) supporting the per-user, failure-burst, and geo cron queries. - Minimal admin-read ACL added so subsequent tests can verify writes. - 3 TransactionCase tests passing: model create, failure_reason nullable on success, geo_lookup_state='internal' accepted. Odoo 19 deprecation note: this implementation uses the declarative models.Constraint and models.Index attributes (Odoo 19 silently drops the legacy `_sql_constraints = [...]` list and `init()`/raw-SQL pattern with only a warning). Captured in CLAUDE.md rule #9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
86 lines
3.4 KiB
Python
86 lines
3.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import fields, models
|
|
|
|
|
|
class FusionLoginAudit(models.Model):
|
|
_name = 'fusion.login.audit'
|
|
_description = 'Login Audit Event'
|
|
_order = 'event_time desc, id desc'
|
|
_rec_name = 'attempted_login'
|
|
|
|
user_id = fields.Many2one(
|
|
'res.users', string='User', ondelete='set null', index=True,
|
|
help='Null when the attempted login did not match any user.',
|
|
)
|
|
attempted_login = fields.Char(
|
|
string='Attempted Login', size=255, required=True, index=True,
|
|
)
|
|
result = fields.Selection(
|
|
[('success', 'Success'), ('failure', 'Failure')],
|
|
string='Result', required=True, index=True,
|
|
)
|
|
failure_reason = fields.Selection(
|
|
[
|
|
('bad_password', 'Bad password'),
|
|
('unknown_user', 'Unknown user'),
|
|
('disabled_user', 'Disabled user'),
|
|
('2fa_failed', '2FA failed'),
|
|
('other', 'Other'),
|
|
],
|
|
string='Failure Reason',
|
|
)
|
|
event_time = fields.Datetime(
|
|
string='Event Time', required=True, index=True,
|
|
default=fields.Datetime.now,
|
|
)
|
|
ip_address = fields.Char(string='IP Address', size=45)
|
|
ip_hostname = fields.Char(string='Reverse DNS', size=255)
|
|
country_code = fields.Char(string='Country Code', size=2, index=True)
|
|
country_name = fields.Char(string='Country', size=64)
|
|
city = fields.Char(string='City', size=128)
|
|
geo_state = fields.Char(string='Region', size=64)
|
|
geo_lookup_state = fields.Selection(
|
|
[
|
|
('pending', 'Pending'),
|
|
('done', 'Done'),
|
|
('private_ip', 'Private IP'),
|
|
('internal', 'Internal (no request)'),
|
|
('failed', 'Lookup failed'),
|
|
],
|
|
string='Geo Lookup State', default='pending', index=True,
|
|
)
|
|
user_agent_raw = fields.Char(string='User Agent', size=512)
|
|
browser = fields.Char(string='Browser', size=64)
|
|
os = fields.Char(string='OS', size=64)
|
|
device_type = fields.Selection(
|
|
[
|
|
('desktop', 'Desktop'),
|
|
('mobile', 'Mobile'),
|
|
('tablet', 'Tablet'),
|
|
('bot', 'Bot'),
|
|
('unknown', 'Unknown'),
|
|
],
|
|
string='Device Type', default='unknown',
|
|
)
|
|
database = fields.Char(string='Database', size=64)
|
|
|
|
# Odoo 19 replaces the legacy `_sql_constraints = [...]` list with
|
|
# declarative `models.Constraint` attributes. The plan template used the
|
|
# legacy form, which now only emits a warning and is silently dropped.
|
|
_result_failure_reason_consistent = models.Constraint(
|
|
"CHECK ((result = 'success' AND failure_reason IS NULL) "
|
|
"OR (result = 'failure' AND failure_reason IS NOT NULL))",
|
|
'A failure row must have a failure_reason; a success row must not.',
|
|
)
|
|
|
|
# Composite indexes supporting the three hot queries:
|
|
# - per-user history (user_id, event_time DESC)
|
|
# - failure-burst by login (attempted_login, event_time DESC)
|
|
# - geo cron worklist (geo_lookup_state, event_time)
|
|
# Odoo 19 ships `models.Index` as the declarative replacement for the
|
|
# init()/raw-SQL pattern; the attribute name becomes the index suffix
|
|
# (e.g. `_user_time_idx` -> `fusion_login_audit_user_time_idx`).
|
|
_user_time_idx = models.Index('(user_id, event_time DESC)')
|
|
_login_time_idx = models.Index('(attempted_login, event_time DESC)')
|
|
_geo_state_idx = models.Index('(geo_lookup_state, event_time)')
|