Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion spp_hazard/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ access_spp_hazard_category_user,spp.hazard.category user,model_spp_hazard_catego
access_spp_hazard_incident_user,spp.hazard.incident user,model_spp_hazard_incident,base.group_user,1,0,0,0
access_spp_hazard_incident_area_user,spp.hazard.incident.area user,model_spp_hazard_incident_area,base.group_user,1,0,0,0
access_spp_hazard_impact_type_user,spp.hazard.impact.type user,model_spp_hazard_impact_type,base.group_user,1,0,0,0
access_spp_hazard_impact_user,spp.hazard.impact user,model_spp_hazard_impact,base.group_user,1,0,0,0
access_spp_hazard_category_sysadmin,Hazard Category System Admin,model_spp_hazard_category,base.group_system,1,1,1,1
access_spp_hazard_incident_sysadmin,Hazard Incident System Admin,model_spp_hazard_incident,base.group_system,1,1,1,1
access_spp_hazard_incident_area_sysadmin,Hazard Incident Area System Admin,model_spp_hazard_incident_area,base.group_system,1,1,1,1
Expand Down
2 changes: 2 additions & 0 deletions spp_hazard/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
from . import test_geofence
from . import test_alert_ingestion
from . import test_registrant

from . import test_acl_group_user
107 changes: 107 additions & 0 deletions spp_hazard/tests/test_acl_group_user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Security: hazard models must not be readable by every internal user.

Regression test for "Broad internal read access exposes hazard impact records":
the ACL granted ``base.group_user`` read on the hazard models, so any internal
user could read hazard data (including registrant-linked impact records) via RPC,
even without a hazard role. Access must require a dedicated hazard group (or
``registry_viewer``/admin), not merely being an internal user.
"""

from odoo import Command
from odoo.exceptions import AccessError
from odoo.tests import tagged

from .common import HazardTestCase

# The registrant-linked impact model is sensitive and must NOT be readable by
# every internal user. The other hazard models are non-PII reference/operational
# data that sibling modules (e.g. spp_drims) legitimately read broadly.
SENSITIVE_MODEL = "spp.hazard.impact"
NON_SENSITIVE_MODELS = [
"spp.hazard.category",
"spp.hazard.incident",
"spp.hazard.incident.area",
"spp.hazard.impact.type",
]
ALL_HAZARD_MODELS = [SENSITIVE_MODEL, *NON_SENSITIVE_MODELS]


@tagged("post_install", "-at_install")
class TestHazardBaseUserNoAccess(HazardTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.plain_user = cls.env["res.users"].create(
{
"name": "Plain Internal User",
"login": "plain_internal_hazard_test",
"group_ids": [Command.link(cls.env.ref("base.group_user").id)],
}
)

def test_plain_internal_user_cannot_read_impact(self):
"""base.group_user (any internal user) must NOT read the sensitive impact model."""
with self.assertRaises(AccessError):
self.env[SENSITIVE_MODEL].with_user(self.plain_user).check_access("read")

def test_plain_internal_user_can_read_non_sensitive_models(self):
"""Non-PII hazard reference/operational models remain internally readable
(sibling modules such as spp_drims depend on reading incidents)."""
for model in NON_SENSITIVE_MODELS:
# Raises AccessError only if broad read was wrongly removed here.
self.env[model].with_user(self.plain_user).check_access("read")

def test_hazard_viewer_retains_read(self):
"""A hazard-group user must keep read access to all hazard models."""
for model in ALL_HAZARD_MODELS:
self.env[model].with_user(self.hazard_viewer).check_access("read")

def test_registry_user_can_still_read_registrant_hazard_fields(self):
"""Regression: the registrant form's hazard indicator fields read
spp.hazard.impact in their compute. A registry user (Officer implies
Registry Viewer, which retains hazard read) must still be able to load
them after the ACL tightening — i.e. the fix must not break the form."""
officer = self.env["res.users"].create(
{
"name": "Registry Officer (no hazard group)",
"login": "registry_officer_hazard_test",
"group_ids": [Command.link(self.env.ref("spp_registry.group_registry_officer").id)],
}
)
# Sanity: this user is NOT in any hazard group.
self.assertFalse(officer.has_group("spp_hazard.group_hazard_read"))

incident = self.env["spp.hazard.incident"].create(
{
"name": "Registry Officer Incident",
"code": "ROI-HAZ-001",
"category_id": self.category_typhoon.id,
"start_date": "2024-01-01",
}
)
self.env["spp.hazard.impact"].create(
{
"incident_id": incident.id,
"registrant_id": self.registrant.id,
"impact_type_id": self.impact_type_displacement.id,
"damage_level": "moderate",
"impact_date": "2024-01-02",
}
)
registrant_as_officer = self.registrant.with_user(officer)
# Force a live read through the impact O2M (not just the stored count),
# which must not raise AccessError for a registry user.
self.assertEqual(registrant_as_officer.hazard_impact_ids.mapped("damage_level"), ["moderate"])

def test_incident_form_hides_impacts_from_non_hazard_user(self):
"""The incident form's Impacts O2M reads spp.hazard.impact; it must be
stripped from the arch for a user without impact read (e.g. a DRIMS-only
user), so opening an incident does not raise AccessError."""
arch = self.env["spp.hazard.incident"].with_user(self.plain_user).get_view(view_type="form")["arch"]
self.assertNotIn("impact_ids", arch)

def test_incident_form_shows_impacts_to_hazard_user(self):
"""A hazard user still gets the Impacts O2M on the incident form."""
arch = self.env["spp.hazard.incident"].with_user(self.hazard_viewer).get_view(view_type="form")["arch"]
self.assertIn("impact_ids", arch)
7 changes: 6 additions & 1 deletion spp_hazard/views/hazard_incident_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
type="object"
class="oe_stat_button"
icon="fa-users"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
>
<field
name="affected_registrant_count"
Expand Down Expand Up @@ -241,7 +242,11 @@
</list>
</field>
</page>
<page string="Impacts" name="impacts">
<page
string="Impacts"
name="impacts"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
>
<p
class="text-muted"
invisible="impact_ids != []"
Expand Down
16 changes: 14 additions & 2 deletions spp_hazard/views/registrant_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
class="oe_stat_button"
icon="fa-bolt"
invisible="hazard_impact_count == 0"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
>
<field
name="hazard_impact_count"
Expand All @@ -30,6 +31,7 @@
string="Emergency Response"
name="emergency_response"
invisible="not is_registrant"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
>
<field name="has_active_impact" invisible="1" />
<div
Expand Down Expand Up @@ -103,11 +105,17 @@
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//list" position="inside">
<field name="hazard_impact_count" string="Impacts" optional="hide" />
<field
name="hazard_impact_count"
string="Impacts"
optional="hide"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
/>
<field
name="has_active_impact"
string="Active Impact"
optional="hide"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
/>
</xpath>
</field>
Expand All @@ -121,16 +129,20 @@
<field name="priority">50</field>
<field name="arch" type="xml">
<xpath expr="//search" position="inside">
<separator />
<separator
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
/>
<filter
name="has_active_impact"
string="Has Active Impact"
domain="[('has_active_impact', '=', True)]"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
/>
<filter
name="has_impact"
string="Has Any Impact"
domain="[('hazard_impact_count', '>', 0)]"
groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin"
/>
</xpath>
</field>
Expand Down
15 changes: 11 additions & 4 deletions spp_hazard_programs/models/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,12 @@ def _compute_affected_registrant_count(self):
# Build domain for qualifying damage levels
damage_domain = rec._get_damage_level_domain()

# Count unique registrants with qualifying impacts
impacts = self.env["spp.hazard.impact"].search(
# Count unique registrants with qualifying impacts.
# sudo: emergency-program eligibility must consider all qualifying
# impacts regardless of the viewing user's hazard access; impact rows
# are not exposed, only the aggregate count.
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
impacts = impact_sudo.search(
Comment on lines +91 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance Bottleneck: Memory Overhead & N+1 Queries

Using search() followed by mapped('registrant_id') loads all matching spp.hazard.impact records into the ORM cache, and then fetches the related res.partner records. In production environments with a large number of impacts, this will cause significant memory overhead and slow down the compute method.

Since you only need the count of unique registrants, you should use _read_group to perform the aggregation directly at the database level. This avoids loading any records into memory.

Suggested change
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
impacts = impact_sudo.search(
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
groups = impact_sudo._read_group(
[
("incident_id", "in", rec.target_incident_ids.ids),
("verification_status", "=", "verified"),
]
+ damage_domain,
groupby=["registrant_id"],
)
rec.affected_registrant_count = len(groups)
continue

[
("incident_id", "in", rec.target_incident_ids.ids),
("verification_status", "=", "verified"),
Expand Down Expand Up @@ -123,8 +127,11 @@ def get_emergency_eligible_registrants(self):

damage_domain = self._get_damage_level_domain()

# Find qualifying impacts
impacts = self.env["spp.hazard.impact"].search(
# Find qualifying impacts.
# sudo: eligibility must consider all qualifying impacts regardless of the
# viewing user's hazard access; only the resulting registrants are returned.
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
impacts = impact_sudo.search(
Comment on lines +133 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Performance Bottleneck: Memory Overhead

Using search() followed by mapped('registrant_id') loads all matching spp.hazard.impact records into memory just to extract the registrant IDs.

You can optimize this by using _read_group to fetch only the unique registrant_ids from the database, and then using browse() to return the partner recordset. This avoids loading any impact records.

Suggested change
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
impacts = impact_sudo.search(
impact_sudo = self.env["spp.hazard.impact"].sudo() # nosemgrep: odoo-sudo-without-context
groups = impact_sudo._read_group(
[
("incident_id", "in", self.target_incident_ids.ids),
("verification_status", "=", "verified"),
]
+ damage_domain,
groupby=["registrant_id"],
)
return self.env["res.partner"].browse([r.id for r, in groups if r])

[
("incident_id", "in", self.target_incident_ids.ids),
("verification_status", "=", "verified"),
Expand Down
2 changes: 2 additions & 0 deletions spp_hazard_programs/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.

from . import test_hazard_programs

from . import test_program_user_access
49 changes: 49 additions & 0 deletions spp_hazard_programs/tests/test_program_user_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Regression: emergency-eligibility logic must work for non-hazard program users.

After tightening the hazard-impact ACL (removing the broad ``base.group_user``
read grant), the program eligibility computes read ``spp.hazard.impact`` via
``sudo`` so a program user without any hazard group can still use them. Without
that sudo, ``affected_registrant_count`` / ``get_emergency_eligible_registrants``
would raise ``AccessError`` for such users.
"""

from odoo import Command
from odoo.tests import tagged

from .common import HazardProgramsTestCase


@tagged("post_install", "-at_install")
class TestProgramUserHazardAccess(HazardProgramsTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.program_user = cls.env["res.users"].create(
{
"name": "Program Manager (no hazard group)",
"login": "program_mgr_no_hazard_test",
"group_ids": [
Command.link(cls.env.ref("base.group_user").id),
Command.link(cls.env.ref("spp_programs.group_programs_manager").id),
],
}
)
cls.program.write(
{
"target_incident_ids": [Command.link(cls.incident_active.id)],
"qualifying_damage_levels": "any",
}
)

def test_program_user_without_hazard_group_can_compute_eligibility(self):
"""A program user with no hazard group must still compute emergency
eligibility (the impact reads are sudo'd)."""
self.assertFalse(self.program_user.has_group("spp_hazard.group_hazard_read"))
program = self.program.with_user(self.program_user)
# Non-stored compute -> runs live as this user; reads impact via sudo.
self.assertEqual(program.affected_registrant_count, 2)
# Method -> runs live as this user; reads impact via sudo.
eligible = program.get_emergency_eligible_registrants()
self.assertIn(self.registrant_1, eligible)
self.assertIn(self.registrant_2, eligible)
Loading