Asserts the smart-button and Login Activity tab fields are stripped from res.users get_view() for non-admin users, and present for Settings admins. Locks down the contract behind the groups="base.group_system" XML attributes on the form-inheritance view (the inherited view record cannot carry groups itself per CLAUDE.md rule #11; the gate must live on the inner nodes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
6.8 KiB
Python
130 lines
6.8 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.
|
|
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
# T14: view-level visibility checks. The smart button and the "Login
|
|
# Activity" tab on res.users are gated by groups="base.group_system"
|
|
# on the inner XML nodes (the inherited view record itself cannot
|
|
# carry groups — CLAUDE.md rule #11). Verify the gate works by asking
|
|
# for the form view as a non-admin and confirming the x_fc_* fields
|
|
# are stripped from the arch.
|
|
# ─────────────────────────────────────────────────────────────────────
|
|
|
|
def test_view_hides_button_and_tab_for_non_admin(self):
|
|
"""A regular user's get_view() does not contain the x_fc_login_audit_*
|
|
fields — they live behind groups="base.group_system" XML attributes."""
|
|
view = self.env['res.users'].with_user(self.regular_user).get_view(
|
|
view_id=self.env.ref('base.view_users_form').id,
|
|
view_type='form',
|
|
)
|
|
arch = view['arch']
|
|
self.assertNotIn('x_fc_login_audit_count', arch,
|
|
"Smart-button field must not leak into non-admin view")
|
|
self.assertNotIn('x_fc_login_audit_ids', arch,
|
|
"Login Activity tab must not leak into non-admin view")
|
|
|
|
def test_view_shows_button_and_tab_for_admin(self):
|
|
"""A Settings admin DOES see both nodes."""
|
|
admin = self.env.ref('base.user_admin')
|
|
view = self.env['res.users'].with_user(admin).get_view(
|
|
view_id=self.env.ref('base.view_users_form').id,
|
|
view_type='form',
|
|
)
|
|
arch = view['arch']
|
|
self.assertIn('x_fc_login_audit_count', arch)
|
|
self.assertIn('x_fc_login_audit_ids', arch)
|