diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst
index 4634a07c0..ff3b65dcc 100644
--- a/spp_hazard/README.rst
+++ b/spp_hazard/README.rst
@@ -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
~~~~~~~~~~
diff --git a/spp_hazard/__manifest__.py b/spp_hazard/__manifest__.py
index 336923eb1..97f454468 100644
--- a/spp_hazard/__manifest__.py
+++ b/spp_hazard/__manifest__.py
@@ -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",
diff --git a/spp_hazard/readme/HISTORY.md b/spp_hazard/readme/HISTORY.md
index c02593c5b..24041ef76 100644
--- a/spp_hazard/readme/HISTORY.md
+++ b/spp_hazard/readme/HISTORY.md
@@ -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.
diff --git a/spp_hazard/security/ir.model.access.csv b/spp_hazard/security/ir.model.access.csv
index 36c1b5f6f..826732791 100644
--- a/spp_hazard/security/ir.model.access.csv
+++ b/spp_hazard/security/ir.model.access.csv
@@ -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
diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html
index 9df1ed8e5..3110f6ed5 100644
--- a/spp_hazard/static/description/index.html
+++ b/spp_hazard/static/description/index.html
@@ -2454,6 +2454,18 @@
+
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
@@ -2469,7 +2481,7 @@
19.0.2.0.2
Support).
-
+
19.0.2.0.1
- fix(views): apply spp_registry.x2many_no_padding widget to the
@@ -2477,7 +2489,7 @@
19.0.2.0.1
(showing a muted info line instead) (#943).
-
+
19.0.2.0.0
- Initial migration to OpenSPP2
diff --git a/spp_hazard/tests/__init__.py b/spp_hazard/tests/__init__.py
index 9956de3b9..700f0a64e 100644
--- a/spp_hazard/tests/__init__.py
+++ b/spp_hazard/tests/__init__.py
@@ -6,3 +6,5 @@
from . import test_hazard_impact_type
from . import test_geofence
from . import test_registrant
+
+from . import test_acl_group_user
diff --git a/spp_hazard/tests/test_acl_group_user.py b/spp_hazard/tests/test_acl_group_user.py
new file mode 100644
index 000000000..4f63b8cfe
--- /dev/null
+++ b/spp_hazard/tests/test_acl_group_user.py
@@ -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)
diff --git a/spp_hazard/views/hazard_incident_views.xml b/spp_hazard/views/hazard_incident_views.xml
index 50a7f6c28..a346e2569 100644
--- a/spp_hazard/views/hazard_incident_views.xml
+++ b/spp_hazard/views/hazard_incident_views.xml
@@ -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"
>
-
+
diff --git a/spp_hazard/views/registrant_views.xml b/spp_hazard/views/registrant_views.xml
index c7a0f7c7b..574fe5148 100644
--- a/spp_hazard/views/registrant_views.xml
+++ b/spp_hazard/views/registrant_views.xml
@@ -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"
>
50
-
+
@@ -121,16 +129,20 @@
50
-
+
diff --git a/spp_hazard_programs/README.rst b/spp_hazard_programs/README.rst
index 0b6fdee90..2d9b09aac 100644
--- a/spp_hazard_programs/README.rst
+++ b/spp_hazard_programs/README.rst
@@ -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
~~~~~~~~~~
diff --git a/spp_hazard_programs/__manifest__.py b/spp_hazard_programs/__manifest__.py
index 1871df01a..dfb115f96 100644
--- a/spp_hazard_programs/__manifest__.py
+++ b/spp_hazard_programs/__manifest__.py
@@ -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",
diff --git a/spp_hazard_programs/models/program.py b/spp_hazard_programs/models/program.py
index 4d48edc0e..0778b030d 100644
--- a/spp_hazard_programs/models/program.py
+++ b/spp_hazard_programs/models/program.py
@@ -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(
[
("incident_id", "in", rec.target_incident_ids.ids),
("verification_status", "=", "verified"),
@@ -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(
[
("incident_id", "in", self.target_incident_ids.ids),
("verification_status", "=", "verified"),
diff --git a/spp_hazard_programs/readme/HISTORY.md b/spp_hazard_programs/readme/HISTORY.md
index 4aaf9afef..329ff0b7d 100644
--- a/spp_hazard_programs/readme/HISTORY.md
+++ b/spp_hazard_programs/readme/HISTORY.md
@@ -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
diff --git a/spp_hazard_programs/static/description/index.html b/spp_hazard_programs/static/description/index.html
index 8a1fc0b68..68329ad5b 100644
--- a/spp_hazard_programs/static/description/index.html
+++ b/spp_hazard_programs/static/description/index.html
@@ -698,6 +698,17 @@
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
- Initial migration to OpenSPP2
diff --git a/spp_hazard_programs/tests/__init__.py b/spp_hazard_programs/tests/__init__.py
index 66fbca0e0..669012f1b 100644
--- a/spp_hazard_programs/tests/__init__.py
+++ b/spp_hazard_programs/tests/__init__.py
@@ -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
diff --git a/spp_hazard_programs/tests/test_program_user_access.py b/spp_hazard_programs/tests/test_program_user_access.py
new file mode 100644
index 000000000..bdfe7bed1
--- /dev/null
+++ b/spp_hazard_programs/tests/test_program_user_access.py
@@ -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)