Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 11 additions & 0 deletions spp_hazard/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,17 @@ encounter unexpected behavior, please report it as a new issue.
Changelog
=========

19.0.2.0.3
~~~~~~~~~~

- fix(security): remove the ``base.group_user`` read grant on
``spp.hazard.impact`` so registrant-linked impact records (name,
damage level, verification, notes) are readable only by hazard roles,
``registry_viewer``, and admins — not every internal user via RPC.
Gate the impact UI on the registrant and incident forms (stat buttons,
Emergency Response / Impacts pages, list columns, search filters) to
users with impact read.

19.0.2.0.2
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_hazard/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"for emergency response. Links registrants to disaster events with geographic scope "
"and severity tracking to enable targeted humanitarian assistance.",
"category": "OpenSPP/Targeting",
"version": "19.0.2.0.2",
"version": "19.0.2.0.3",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
4 changes: 4 additions & 0 deletions spp_hazard/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.2.0.3

- fix(security): remove the `base.group_user` read grant on `spp.hazard.impact` so registrant-linked impact records (name, damage level, verification, notes) are readable only by hazard roles, `registry_viewer`, and admins — not every internal user via RPC. Gate the impact UI on the registrant and incident forms (stat buttons, Emergency Response / Impacts pages, list columns, search filters) to users with impact read.

### 19.0.2.0.2

- fix(security): grant `group_hazard_viewer` to spp_user_roles roles (Registry Viewer, Program Manager, Global/Local Registrar) that the OP#951 menu audit identifies as needing read-only Hazard menu access. Other affected roles defined outside this module (program/CR/farm roles) are wired in their own modules.
Expand Down
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
16 changes: 14 additions & 2 deletions spp_hazard/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2454,6 +2454,18 @@ <h2><a class="toc-backref" href="#toc-entry-2">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.3</h1>
<ul class="simple">
<li>fix(security): remove the <tt class="docutils literal">base.group_user</tt> read grant on
<tt class="docutils literal">spp.hazard.impact</tt> so registrant-linked impact records (name,
damage level, verification, notes) are readable only by hazard roles,
<tt class="docutils literal">registry_viewer</tt>, and admins — not every internal user via RPC.
Gate the impact UI on the registrant and incident forms (stat buttons,
Emergency Response / Impacts pages, list columns, search filters) to
users with impact read.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.2</h1>
<ul class="simple">
<li>fix(security): grant <tt class="docutils literal">group_hazard_viewer</tt> to spp_user_roles roles
Expand All @@ -2469,15 +2481,15 @@ <h1>19.0.2.0.2</h1>
Support).</li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.2.0.1</h1>
<ul class="simple">
<li>fix(views): apply <tt class="docutils literal">spp_registry.x2many_no_padding</tt> widget to the
hazard impacts list on registrant forms, and hide the table when empty
(showing a muted info line instead) (#943).</li>
</ul>
</div>
<div class="section" id="section-3">
<div class="section" id="section-4">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
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 @@ -6,3 +6,5 @@
from . import test_hazard_impact_type
from . import test_geofence
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 @@ -85,6 +85,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 @@ -181,7 +182,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"
>
<field name="impact_ids" readonly="status == 'closed'">
<list editable="bottom" no_open="true">
<field name="registrant_id" />
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
10 changes: 10 additions & 0 deletions spp_hazard_programs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,16 @@ Test Scenario 9: Incident List View Column
Changelog
=========

19.0.2.0.1
~~~~~~~~~~

- fix(security): read ``spp.hazard.impact`` via ``sudo`` in the
emergency-eligibility computes (``affected_registrant_count``,
``get_emergency_eligible_registrants``), so they keep working for
non-hazard program users after impact read access was restricted to
hazard/registry roles. Only aggregate counts / eligible registrants
are surfaced, not impact rows.

19.0.2.0.0
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_hazard_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"summary": "Links hazard impacts to program eligibility and entitlements. "
"Enables emergency programs to use hazard data for targeting and benefit calculation.",
"category": "OpenSPP/Targeting",
"version": "19.0.2.0.0",
"version": "19.0.2.0.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
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
4 changes: 4 additions & 0 deletions spp_hazard_programs/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.2.0.1

- fix(security): read `spp.hazard.impact` via `sudo` in the emergency-eligibility computes (`affected_registrant_count`, `get_emergency_eligible_registrants`), so they keep working for non-hazard program users after impact read access was restricted to hazard/registry roles. Only aggregate counts / eligible registrants are surfaced, not impact rows.

### 19.0.2.0.0

- Initial migration to OpenSPP2
11 changes: 11 additions & 0 deletions spp_hazard_programs/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,17 @@ <h2>Changelog</h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.1</h1>
<ul class="simple">
<li>fix(security): read <tt class="docutils literal">spp.hazard.impact</tt> via <tt class="docutils literal">sudo</tt> in the
emergency-eligibility computes (<tt class="docutils literal">affected_registrant_count</tt>,
<tt class="docutils literal">get_emergency_eligible_registrants</tt>), so they keep working for
non-hazard program users after impact read access was restricted to
hazard/registry roles. Only aggregate counts / eligible registrants
are surfaced, not impact rows.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
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
Loading
Loading