feat(fusion_login_audit): add fusion.login.audit model
- 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>
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Odoo runtime
|
||||
*.pyc-tmp
|
||||
|
||||
# Local-only diagnostic logs from test runs
|
||||
_test_*.log
|
||||
@@ -1,2 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import tests
|
||||
|
||||
@@ -20,7 +20,9 @@ bursts. Daily retention cron honours a configurable horizon.
|
||||
'website': 'https://nexasystems.ca',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['base', 'mail'],
|
||||
'data': [],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Files added in later tasks
|
||||
from . import fusion_login_audit
|
||||
|
||||
85
fusion_login_audit/models/fusion_login_audit.py
Normal file
85
fusion_login_audit/models/fusion_login_audit.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- 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)')
|
||||
2
fusion_login_audit/security/ir.model.access.csv
Normal file
2
fusion_login_audit/security/ir.model.access.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_login_audit_system,fusion.login.audit system,model_fusion_login_audit,base.group_system,1,0,0,0
|
||||
|
2
fusion_login_audit/tests/__init__.py
Normal file
2
fusion_login_audit/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import test_login_audit
|
||||
43
fusion_login_audit/tests/test_login_audit.py
Normal file
43
fusion_login_audit/tests/test_login_audit.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionLoginAuditModel(TransactionCase):
|
||||
|
||||
def test_model_exists_and_creates(self):
|
||||
"""Audit row can be created with all expected fields."""
|
||||
Audit = self.env['fusion.login.audit'].sudo()
|
||||
rec = Audit.create({
|
||||
'attempted_login': 'demo@example.com',
|
||||
'result': 'success',
|
||||
'ip_address': '203.0.113.5',
|
||||
'user_agent_raw': 'Mozilla/5.0 Test',
|
||||
'browser': 'Test 1.0',
|
||||
'os': 'TestOS',
|
||||
'device_type': 'desktop',
|
||||
'database': self.env.cr.dbname,
|
||||
'geo_lookup_state': 'pending',
|
||||
})
|
||||
self.assertTrue(rec.id)
|
||||
self.assertEqual(rec.result, 'success')
|
||||
self.assertEqual(rec.geo_lookup_state, 'pending')
|
||||
self.assertEqual(rec.database, self.env.cr.dbname)
|
||||
self.assertTrue(rec.event_time) # default fires
|
||||
|
||||
def test_failure_reason_optional(self):
|
||||
"""failure_reason is null on success rows."""
|
||||
rec = self.env['fusion.login.audit'].sudo().create({
|
||||
'attempted_login': 'demo@example.com',
|
||||
'result': 'success',
|
||||
})
|
||||
self.assertFalse(rec.failure_reason)
|
||||
|
||||
def test_geo_state_internal_value(self):
|
||||
"""`internal` is an accepted geo_lookup_state value (distinct from private_ip)."""
|
||||
rec = self.env['fusion.login.audit'].sudo().create({
|
||||
'attempted_login': 'demo@example.com',
|
||||
'result': 'success',
|
||||
'geo_lookup_state': 'internal',
|
||||
})
|
||||
self.assertEqual(rec.geo_lookup_state, 'internal')
|
||||
Reference in New Issue
Block a user