diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..a23253dc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/fusion_login_audit/__init__.py b/fusion_login_audit/__init__.py index a0fdc10f..c98792e9 100644 --- a/fusion_login_audit/__init__.py +++ b/fusion_login_audit/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import models +from . import tests diff --git a/fusion_login_audit/__manifest__.py b/fusion_login_audit/__manifest__.py index 98f3f256..d9fc387c 100644 --- a/fusion_login_audit/__manifest__.py +++ b/fusion_login_audit/__manifest__.py @@ -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, diff --git a/fusion_login_audit/models/__init__.py b/fusion_login_audit/models/__init__.py index be303789..e966d1fc 100644 --- a/fusion_login_audit/models/__init__.py +++ b/fusion_login_audit/models/__init__.py @@ -1,2 +1,2 @@ # -*- coding: utf-8 -*- -# Files added in later tasks +from . import fusion_login_audit diff --git a/fusion_login_audit/models/fusion_login_audit.py b/fusion_login_audit/models/fusion_login_audit.py new file mode 100644 index 00000000..3848c21a --- /dev/null +++ b/fusion_login_audit/models/fusion_login_audit.py @@ -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)') diff --git a/fusion_login_audit/security/ir.model.access.csv b/fusion_login_audit/security/ir.model.access.csv new file mode 100644 index 00000000..11e712e0 --- /dev/null +++ b/fusion_login_audit/security/ir.model.access.csv @@ -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 diff --git a/fusion_login_audit/tests/__init__.py b/fusion_login_audit/tests/__init__.py new file mode 100644 index 00000000..9479bf78 --- /dev/null +++ b/fusion_login_audit/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import test_login_audit diff --git a/fusion_login_audit/tests/test_login_audit.py b/fusion_login_audit/tests/test_login_audit.py new file mode 100644 index 00000000..73abc737 --- /dev/null +++ b/fusion_login_audit/tests/test_login_audit.py @@ -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')