Record rule grants admins an unrestricted domain on the audit log;
ACL forbids write/create/unlink for every group (audit is append-only;
sudo() inside auth hooks is the only write path). Defence-in-depth
layering: ACL is the actual gate, the rule documents and locks down
admin access path.
Tests (5, all green) cover:
test_admin_can_read_through_acl_and_rule — positive path through both.
test_acl_blocks_read_for_regular_user — base.group_user denied by ACL.
test_acl_blocks_read_for_portal_user — base.group_portal share user
denied (sensitive data leakage
surface closed at ACL layer).
test_acl_blocks_write_for_admin — append-only at the write boundary.
test_acl_blocks_unlink_for_admin — append-only at the unlink boundary.
Drop the redundant `from . import tests` from the root __init__.py —
Odoo's test loader imports `odoo.addons.<mod>.tests` directly; the
extra import was dead weight (and inconsistent with the repo pattern).
CLAUDE.md gotchas added during this task:
#6 res.users.groups_id -> group_ids rename (test setUp pitfall).
#6 ir.rule `groups` is additive, not restrictive — group-scoped
rules only apply to users in that group, they do not restrict
non-members. Default to letting the ACL gate; use rules for
row-level filters ACLs cannot express.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
4.9 KiB
Python
97 lines
4.9 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo.exceptions import AccessError
|
|
from odoo.tests.common import TransactionCase, tagged
|
|
|
|
|
|
@tagged('post_install', '-at_install')
|
|
class TestFusionLoginAuditSecurity(TransactionCase):
|
|
"""Tests for the layered protection on `fusion.login.audit`:
|
|
|
|
Layer 1 — ACL (security/ir.model.access.csv): grants read-only access to
|
|
`base.group_system` and nothing to any other group. Blocks write/create/
|
|
unlink for everyone via the ORM regardless of `sudo()`.
|
|
|
|
Layer 2 — Record rule (security/security.xml): group-specific rule that
|
|
grants admins an unrestricted domain (`[(1,'=',1)]`). The rule does NOT
|
|
actively restrict non-admins — Odoo's semantics for a group-scoped rule
|
|
is "the rule only applies to users in that group". Non-admins are gated
|
|
purely by the ACL, which denies them everything. The rule's value is
|
|
documentation + future-proofing (it keeps admin access explicit if the
|
|
ACL is ever loosened with a per-group read row; the admin path remains
|
|
explicit and self-documenting). It is NOT a security gate the ACL relies on.
|
|
|
|
Test naming reflects which layer actually does the work:
|
|
- test_acl_blocks_* — exercises Layer 1 (ACL alone is sufficient).
|
|
- test_admin_can_read_through_acl_and_rule — exercises both layers in
|
|
the positive path (admin must satisfy ACL grant
|
|
AND the admin-scoped rule's domain).
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.audit_row = self.env['fusion.login.audit'].sudo().create({
|
|
'attempted_login': 'sec-test@example.com',
|
|
'result': 'success',
|
|
'database': self.env.cr.dbname,
|
|
})
|
|
# Internal non-admin user (active employee, not a Settings admin).
|
|
self.regular_user = self.env['res.users'].sudo().create({
|
|
'name': 'Regular Tester',
|
|
'login': 'regular-tester@example.com',
|
|
'password': 'regular-tester-pw-1',
|
|
'group_ids': [(6, 0, [self.env.ref('base.group_user').id])],
|
|
})
|
|
# Portal user (share=True) — must not see audit data either.
|
|
self.portal_user = self.env['res.users'].sudo().create({
|
|
'name': 'Portal Tester',
|
|
'login': 'portal-tester@example.com',
|
|
'password': 'portal-tester-pw-1',
|
|
'group_ids': [(6, 0, [self.env.ref('base.group_portal').id])],
|
|
})
|
|
|
|
def test_admin_can_read_through_acl_and_rule(self):
|
|
"""A Settings admin satisfies both the ACL (grants read) and the
|
|
record rule (admin-only domain), so the read succeeds."""
|
|
admin = self.env.ref('base.user_admin')
|
|
rec = self.audit_row.with_user(admin).read(['attempted_login'])
|
|
self.assertEqual(rec[0]['attempted_login'], 'sec-test@example.com')
|
|
|
|
def test_acl_blocks_read_for_regular_user(self):
|
|
"""A `base.group_user` member has no ACL grant on the model. The
|
|
ACL alone denies the read; the record rule never gets consulted."""
|
|
with self.assertRaises(AccessError):
|
|
self.audit_row.with_user(self.regular_user).read(['attempted_login'])
|
|
|
|
def test_acl_blocks_read_for_portal_user(self):
|
|
"""A `base.group_portal` (share=True) user has no ACL grant either.
|
|
Audit data must never leak to a portal user — IP and attempted_login
|
|
are sensitive."""
|
|
with self.assertRaises(AccessError):
|
|
self.audit_row.with_user(self.portal_user).read(['attempted_login'])
|
|
|
|
def test_acl_blocks_write_for_admin(self):
|
|
"""Even Settings admins cannot write — the ACL grants no group any
|
|
write permission on this model (audit log is append-only). The rule's
|
|
`perm_write=False` means 'rule does not constrain this op', so this
|
|
denial is the ACL's work alone."""
|
|
admin = self.env.ref('base.user_admin')
|
|
with self.assertRaises(AccessError):
|
|
self.audit_row.with_user(admin).write({'attempted_login': 'tampered'})
|
|
|
|
def test_acl_blocks_unlink_for_admin(self):
|
|
"""Append-only also at the unlink boundary. ACL grants no group
|
|
delete permission; the record rule's `perm_unlink=False` exempts
|
|
it from gating this op."""
|
|
admin = self.env.ref('base.user_admin')
|
|
with self.assertRaises(AccessError):
|
|
self.audit_row.with_user(admin).unlink()
|
|
|
|
# Note: a "rule actively blocks non-admins" test was attempted but
|
|
# removed once the actual Odoo semantics were verified. A group-scoped
|
|
# rule (groups=[base.group_system]) only applies to users in that group.
|
|
# Granting a base.group_user member an ACL read row would let them read
|
|
# rows — the rule does not filter them. To make the rule truly restrictive
|
|
# we would need a global rule (groups=[]) with domain [(0,'=',1)] paired
|
|
# with the admin grant. That is a security-model redesign and out of
|
|
# scope for T3. The ACL already provides the actual gate.
|