From 85ddad6334c2cf2beb039efe002b6c50cbe39fcf Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 10 Sep 2026 13:21:22 +0800 Subject: [PATCH 1/4] fix(spp_hazard): gate stored registrant impact indicators with field-level groups Merge-turn review of #262 on the batch-3 staging branch found that res.partner.hazard_impact_count and has_active_impact are stored columns derived from the sensitive impact table, and #262 gated only the list and search views. Any internal user could still search_read / read_group them over RPC and enumerate which registrants are disaster victims, per person. Apply the same field-level groups= as the impact model's read ACL to both fields. All form/list/search elements that reference them already sit inside #262's gated containers, and no other module reads them. The review also claimed that removing base.group_user read on impacts would break partner creation for users without impact read, because the computes query spp.hazard.impact. That does not hold: stored computes run as superuser (compute_sudo defaults to True for stored fields). A test creating a partner as a Contact Creation-only user, flushing in that user's env, pins the behaviour. Tests: spp_hazard 83/83 locally (3 new; the RPC-read test is red without the groups=). HISTORY amended under the unreleased 19.0.2.1.1; no extra bump. --- spp_hazard/models/registrant.py | 7 ++++ spp_hazard/readme/HISTORY.md | 1 + spp_hazard/tests/test_acl_group_user.py | 53 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/spp_hazard/models/registrant.py b/spp_hazard/models/registrant.py index 639120386..7ef34163a 100644 --- a/spp_hazard/models/registrant.py +++ b/spp_hazard/models/registrant.py @@ -18,14 +18,21 @@ class ResPartner(models.Model): "registrant_id", string="Hazard Impacts", ) + # Both indicators are derived from the sensitive impact table and stored on + # the partner, so they are readable through the ORM (search_read, read_group, + # export, search domains) independently of the view-level gating. Field-level + # groups= mirrors the impact model's read ACL so a plain internal user cannot + # enumerate which registrants are disaster victims over RPC. hazard_impact_count = fields.Integer( compute="_compute_hazard_impact_count", string="Impact Count", store=True, + groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin", ) has_active_impact = fields.Boolean( compute="_compute_has_active_impact", store=True, + groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin", help="Whether the registrant has an impact from an active incident", ) diff --git a/spp_hazard/readme/HISTORY.md b/spp_hazard/readme/HISTORY.md index ed8809821..e95d9a1c7 100644 --- a/spp_hazard/readme/HISTORY.md +++ b/spp_hazard/readme/HISTORY.md @@ -1,6 +1,7 @@ ### 19.0.2.1.1 - 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. +- fix(security): guard the stored registrant indicators `res.partner.hazard_impact_count` / `has_active_impact` with the same field-level `groups=` as the impact ACL. Gating only the registrant list/search views left both columns readable over RPC (`search_read`, `read_group`, export, search domains) by any internal user — a per-registrant victim list. Stored computes run as superuser, so partner creation by users without impact read is unaffected (pinned by test). - fix(security): guard `spp.hazard.incident.affected_registrant_count` with field-level `groups=`. `spp.hazard.incident` stays broadly readable (sibling modules read incidents), but this aggregate is derived from the sensitive impact table via raw ACL-bypassing SQL, so a plain internal user could read the affected-registrant count over RPC even without impact read. The field is now restricted to hazard read / `registry_viewer` / admin, which also strips it from the incident list column for other users. ### 19.0.2.1.0 diff --git a/spp_hazard/tests/test_acl_group_user.py b/spp_hazard/tests/test_acl_group_user.py index a06660d83..4e3ac5cc7 100644 --- a/spp_hazard/tests/test_acl_group_user.py +++ b/spp_hazard/tests/test_acl_group_user.py @@ -165,3 +165,56 @@ 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) + + def test_plain_internal_user_cannot_read_registrant_impact_fields(self): + """``res.partner.hazard_impact_count`` / ``has_active_impact`` are stored + columns derived from the sensitive impact table. Gating only the views is + not enough: a plain internal user could still ``search_read`` them over RPC + and enumerate which registrants are disaster victims. The fields must carry + field-level ``groups=`` so the ORM refuses the read.""" + partner_as_plain = self.env["res.partner"].with_user(self.plain_user) + with self.assertRaises(AccessError): + partner_as_plain.search_read( + [("id", "=", self.registrant.id)], + ["name", "hazard_impact_count", "has_active_impact"], + ) + + def test_hazard_viewer_can_read_registrant_impact_fields(self): + """A hazard-group user keeps read on the registrant impact indicator fields.""" + rows = ( + self.env["res.partner"] + .with_user(self.hazard_viewer) + .search_read([("id", "=", self.registrant.id)], ["hazard_impact_count", "has_active_impact"]) + ) + self.assertEqual(len(rows), 1) + + def test_contact_creator_without_impact_read_can_create_partner(self): + """Guard: the two stored impact indicators are computed on every + ``res.partner`` create by querying ``spp.hazard.impact``. Stored computes + run as superuser (``compute_sudo`` defaults to True for stored fields), so + removing ``base.group_user`` read on impacts must NOT break partner + creation for an internal user who may create contacts but holds no + hazard/registry role (e.g. Contact Creation only). Pins that behaviour, + including the flush that actually runs the compute.""" + creator = self.env["res.users"].create( + { + "name": "Contact Creator (no hazard/registry group)", + "login": "contact_creator_hazard_test", + "group_ids": [ + Command.link(self.env.ref("base.group_user").id), + Command.link(self.env.ref("base.group_partner_manager").id), + ], + } + ) + self.assertFalse(creator.has_group("spp_hazard.group_hazard_read")) + self.assertFalse(creator.has_group("spp_registry.group_registry_viewer")) + with self.assertRaises(AccessError): + self.env[SENSITIVE_MODEL].with_user(creator).check_access("read") + + partner = self.env["res.partner"].with_user(creator).create({"name": "Created by contact creator"}) + # Stored computes are deferred to flush time; flush in the CREATOR's env + # (as a real request does at its end) so the compute runs as that user. + partner.env.flush_all() + self.assertTrue(partner.exists()) + self.assertEqual(partner.sudo().hazard_impact_count, 0) + self.assertFalse(partner.sudo().has_active_impact) From 6769d7acd23e9e759bf31d6158eb866a019a7ed9 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 10 Sep 2026 13:33:22 +0800 Subject: [PATCH 2/4] fix(spp_hazard): gate the impact O2M too; pin domain/order/read_group paths; regenerate README Delta review of the fix-up: res.partner.hazard_impact_ids searches spp.hazard.impact in the reading user's env, so a bare read() of a partner by a user without impact read would fail on that field. Give the O2M the same field-level groups= as the two stored indicators. Extend the RPC test to cover the headline attack surface directly: search domains and order= on the gated columns, read_group over them, the O2M in a field list, fields_get() hiding all three, and the list arch stripping the columns. README.rst / index.html regenerated from CI's pinned oca-gen output. spp_hazard ACL suite 12/12 locally. --- spp_hazard/README.rst | 8 ++++++++ spp_hazard/models/registrant.py | 4 ++++ spp_hazard/static/description/index.html | 8 ++++++++ spp_hazard/tests/test_acl_group_user.py | 21 +++++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/spp_hazard/README.rst b/spp_hazard/README.rst index 5d392fbd6..ea9bb06ea 100644 --- a/spp_hazard/README.rst +++ b/spp_hazard/README.rst @@ -1196,6 +1196,14 @@ Changelog 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. +- fix(security): guard the stored registrant indicators + ``res.partner.hazard_impact_count`` / ``has_active_impact`` with the + same field-level ``groups=`` as the impact ACL. Gating only the + registrant list/search views left both columns readable over RPC + (``search_read``, ``read_group``, export, search domains) by any + internal user — a per-registrant victim list. Stored computes run as + superuser, so partner creation by users without impact read is + unaffected (pinned by test). - fix(security): guard ``spp.hazard.incident.affected_registrant_count`` with field-level ``groups=``. ``spp.hazard.incident`` stays broadly readable (sibling modules read incidents), but this aggregate is diff --git a/spp_hazard/models/registrant.py b/spp_hazard/models/registrant.py index 7ef34163a..52328dd78 100644 --- a/spp_hazard/models/registrant.py +++ b/spp_hazard/models/registrant.py @@ -17,6 +17,10 @@ class ResPartner(models.Model): "spp.hazard.impact", "registrant_id", string="Hazard Impacts", + # Reading the O2M searches spp.hazard.impact in the user's env; gate it + # like the impact ACL so a bare read() of a partner by a user without + # impact read does not fail on this field. + groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin", ) # Both indicators are derived from the sensitive impact table and stored on # the partner, so they are readable through the ORM (search_read, read_group, diff --git a/spp_hazard/static/description/index.html b/spp_hazard/static/description/index.html index e095a0991..8db1d981d 100644 --- a/spp_hazard/static/description/index.html +++ b/spp_hazard/static/description/index.html @@ -2463,6 +2463,14 @@

19.0.2.1.1

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. +
  • fix(security): guard the stored registrant indicators +res.partner.hazard_impact_count / has_active_impact with the +same field-level groups= as the impact ACL. Gating only the +registrant list/search views left both columns readable over RPC +(search_read, read_group, export, search domains) by any +internal user — a per-registrant victim list. Stored computes run as +superuser, so partner creation by users without impact read is +unaffected (pinned by test).
  • fix(security): guard spp.hazard.incident.affected_registrant_count with field-level groups=. spp.hazard.incident stays broadly readable (sibling modules read incidents), but this aggregate is diff --git a/spp_hazard/tests/test_acl_group_user.py b/spp_hazard/tests/test_acl_group_user.py index 4e3ac5cc7..1fa2960d3 100644 --- a/spp_hazard/tests/test_acl_group_user.py +++ b/spp_hazard/tests/test_acl_group_user.py @@ -178,6 +178,27 @@ def test_plain_internal_user_cannot_read_registrant_impact_fields(self): [("id", "=", self.registrant.id)], ["name", "hazard_impact_count", "has_active_impact"], ) + # The headline attack is the domain, not the field list: filtering or + # ordering on the gated columns must be refused too (presence oracle). + with self.assertRaises(AccessError): + partner_as_plain.search([("has_active_impact", "=", True)]) + with self.assertRaises(AccessError): + partner_as_plain.search([("hazard_impact_count", ">", 0)]) + with self.assertRaises(AccessError): + partner_as_plain.search([("id", "=", self.registrant.id)], order="hazard_impact_count desc") + with self.assertRaises(AccessError): + partner_as_plain.read_group([], ["hazard_impact_count:sum"], ["has_active_impact"]) + # The O2M itself must not be reachable either, and all three must be + # hidden from fields_get(): that is what makes a bare read() with no + # field list (generic RPC clients) skip them instead of failing on them. + with self.assertRaises(AccessError): + partner_as_plain.search_read([("id", "=", self.registrant.id)], ["hazard_impact_ids"]) + visible = partner_as_plain.fields_get(["hazard_impact_ids", "hazard_impact_count", "has_active_impact"]) + self.assertEqual(visible, {}) + # And the gated columns/filters are stripped from the list/search arch. + arch = partner_as_plain.get_view(view_type="list")["arch"] + self.assertNotIn("hazard_impact_count", arch) + self.assertNotIn("has_active_impact", arch) def test_hazard_viewer_can_read_registrant_impact_fields(self): """A hazard-group user keeps read on the registrant impact indicator fields.""" From 798437e8db000f56f60750422a3514a2f33620a1 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 10 Sep 2026 13:43:50 +0800 Subject: [PATCH 3/4] fix(spp_hazard_programs): require impact read to open the affected-registrant list #262 kept the emergency aggregate (affected_registrant_count) readable by every program user, which is fine: a number. But the 'Affected' stat button and the public get_emergency_eligible_registrants() returned the list of impacted registrants to any spp.program reader with no hazard role, over the UI and over RPC. That list is the identity linkage the impact ACL protects. - action_view_affected_registrants: check_access('read') on spp.hazard.impact before building the action (buttons are RPC-callable). - The stat button carries the same field-level groups= as the impact ACL. - get_emergency_eligible_registrants -> _get_emergency_eligible_registrants: private, so not reachable via call_kw; Python callers and overrides keep working (no overrides exist in this repo, customers or legacy modules). Tests: 3 added in test_program_user_access.py (button stripped + action refused for a program user without impact read; method not public; hazard viewer keeps both); spp_hazard_programs 28/28 locally. HISTORY 19.0.2.0.1 amended (unreleased); DESCRIPTION updated; README regen left to CI. --- spp_hazard_programs/models/program.py | 18 ++++++-- spp_hazard_programs/readme/DESCRIPTION.md | 2 +- spp_hazard_programs/readme/HISTORY.md | 2 +- .../tests/test_hazard_programs.py | 6 +-- .../tests/test_program_user_access.py | 44 ++++++++++++++++++- spp_hazard_programs/views/program_views.xml | 1 + 6 files changed, 63 insertions(+), 10 deletions(-) diff --git a/spp_hazard_programs/models/program.py b/spp_hazard_programs/models/program.py index 66efa57fa..fb1f2791e 100644 --- a/spp_hazard_programs/models/program.py +++ b/spp_hazard_programs/models/program.py @@ -113,10 +113,16 @@ def _get_damage_level_domain(self): return [("damage_level", "in", ("critical", "totally_damaged"))] return [] - def get_emergency_eligible_registrants(self): + def _get_emergency_eligible_registrants(self): """ Get registrants eligible for this emergency program based on hazard impacts. + Private on purpose: the result is the list of registrants affected by a + hazard, i.e. the identity linkage the impact ACL protects. It is meant + for Python callers (eligibility logic, overrides), not for RPC. The UI + entry point is ``action_view_affected_registrants``, which checks impact + read access before exposing the list. + Returns registrants who: - Have verified impact from one of the target incidents - Meet the qualifying damage level threshold @@ -159,9 +165,15 @@ def action_view_target_incidents(self): } def action_view_affected_registrants(self): - """Open a list view of potentially affected registrants.""" + """Open a list view of potentially affected registrants. + + The aggregate count stays visible to every program user, but the list + names the impacted registrants, so it requires impact read access. The + stat button is gated in the view; this check covers RPC callers. + """ self.ensure_one() - registrants = self.get_emergency_eligible_registrants() + self.env["spp.hazard.impact"].check_access("read") + registrants = self._get_emergency_eligible_registrants() return { "name": _("Affected Registrants - %s", self.name), "type": "ir.actions.act_window", diff --git a/spp_hazard_programs/readme/DESCRIPTION.md b/spp_hazard_programs/readme/DESCRIPTION.md index 29f7ab3bd..a2bff17c5 100644 --- a/spp_hazard_programs/readme/DESCRIPTION.md +++ b/spp_hazard_programs/readme/DESCRIPTION.md @@ -39,7 +39,7 @@ No new models or ACL entries. Fields added to existing models inherit access fro ### Extension Points -- Override `get_emergency_eligible_registrants()` to customize eligibility logic beyond damage levels +- Override `_get_emergency_eligible_registrants()` to customize eligibility logic beyond damage levels - Override `_get_damage_level_domain()` to add custom damage filtering rules - Inherit `spp.program` to add fields used in emergency calculations - Use `is_emergency_program` and `is_emergency_mode` flags in downstream program logic diff --git a/spp_hazard_programs/readme/HISTORY.md b/spp_hazard_programs/readme/HISTORY.md index 329ff0b7d..e96a173ff 100644 --- a/spp_hazard_programs/readme/HISTORY.md +++ b/spp_hazard_programs/readme/HISTORY.md @@ -1,6 +1,6 @@ ### 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. +- 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 are surfaced to program users without impact read; the list of eligible (impacted) registrants is the identity linkage the impact ACL protects, so `action_view_affected_registrants` now checks impact read access server-side, its stat button is gated in the form, and `get_emergency_eligible_registrants()` is renamed `_get_emergency_eligible_registrants()` so it is no longer callable over RPC (Python callers and overrides are unaffected). ### 19.0.2.0.0 diff --git a/spp_hazard_programs/tests/test_hazard_programs.py b/spp_hazard_programs/tests/test_hazard_programs.py index 42ccefcbe..bda984b5a 100644 --- a/spp_hazard_programs/tests/test_hazard_programs.py +++ b/spp_hazard_programs/tests/test_hazard_programs.py @@ -137,7 +137,7 @@ def test_get_emergency_eligible_registrants(self): "qualifying_damage_levels": "any", } ) - registrants = self.program.get_emergency_eligible_registrants() + registrants = self.program._get_emergency_eligible_registrants() self.assertEqual(len(registrants), 2) self.assertIn(self.registrant_1, registrants) self.assertIn(self.registrant_2, registrants) @@ -146,7 +146,7 @@ def test_get_emergency_eligible_registrants(self): def test_get_emergency_eligible_registrants_no_incidents(self): """Test eligible registrants returns empty when no incidents linked.""" - registrants = self.program.get_emergency_eligible_registrants() + registrants = self.program._get_emergency_eligible_registrants() self.assertEqual(len(registrants), 0) def test_get_emergency_eligible_registrants_with_filter(self): @@ -157,7 +157,7 @@ def test_get_emergency_eligible_registrants_with_filter(self): "qualifying_damage_levels": "critical_only", } ) - registrants = self.program.get_emergency_eligible_registrants() + registrants = self.program._get_emergency_eligible_registrants() self.assertEqual(len(registrants), 1) self.assertIn(self.registrant_1, registrants) diff --git a/spp_hazard_programs/tests/test_program_user_access.py b/spp_hazard_programs/tests/test_program_user_access.py index bdfe7bed1..3be50bcc3 100644 --- a/spp_hazard_programs/tests/test_program_user_access.py +++ b/spp_hazard_programs/tests/test_program_user_access.py @@ -43,7 +43,47 @@ def test_program_user_without_hazard_group_can_compute_eligibility(self): 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() + # Method -> runs live as this user; reads impact via sudo. Private so it + # is reachable from Python (eligibility, overrides) but not over RPC. + eligible = program._get_emergency_eligible_registrants() self.assertIn(self.registrant_1, eligible) self.assertIn(self.registrant_2, eligible) + + def test_eligible_registrants_method_is_not_rpc_callable(self): + """The eligible-registrant list is the identity linkage the impact ACL + protects. It must not be exposed as a public (call_kw-reachable) method.""" + self.assertFalse(hasattr(type(self.program), "get_emergency_eligible_registrants")) + self.assertTrue(hasattr(type(self.program), "_get_emergency_eligible_registrants")) + + def test_program_user_without_impact_read_cannot_open_affected_registrants(self): + """The 'Affected' stat button opens the list of impacted registrants. A + program user without impact read keeps the aggregate count but must be + refused the list, server-side (buttons are RPC-callable) and in the arch.""" + from odoo.exceptions import AccessError + + program = self.program.with_user(self.program_user) + self.assertEqual(program.affected_registrant_count, 2) + with self.assertRaises(AccessError): + program.action_view_affected_registrants() + arch = self.env["spp.program"].with_user(self.program_user).get_view(view_type="form")["arch"] + self.assertNotIn("action_view_affected_registrants", arch) + + def test_hazard_user_can_open_affected_registrants(self): + """A user with impact read (hazard viewer + program manager) keeps the list.""" + hazard_program_user = self.env["res.users"].create( + { + "name": "Program Manager with hazard read", + "login": "program_mgr_hazard_read_test", + "group_ids": [ + Command.link(self.env.ref("base.group_user").id), + Command.link(self.env.ref("spp_programs.group_programs_manager").id), + Command.link(self.env.ref("spp_hazard.group_hazard_viewer").id), + ], + } + ) + program = self.program.with_user(hazard_program_user) + action = program.action_view_affected_registrants() + self.assertEqual(action["res_model"], "res.partner") + self.assertEqual(set(action["domain"][0][2]), {self.registrant_1.id, self.registrant_2.id}) + arch = self.env["spp.program"].with_user(hazard_program_user).get_view(view_type="form")["arch"] + self.assertIn("action_view_affected_registrants", arch) diff --git a/spp_hazard_programs/views/program_views.xml b/spp_hazard_programs/views/program_views.xml index 910715cd6..5453049ed 100644 --- a/spp_hazard_programs/views/program_views.xml +++ b/spp_hazard_programs/views/program_views.xml @@ -28,6 +28,7 @@ class="oe_stat_button" icon="fa-users" invisible="affected_registrant_count == 0" + groups="spp_hazard.group_hazard_read,spp_registry.group_registry_viewer,spp_security.group_spp_admin" > Date: Thu, 10 Sep 2026 13:51:11 +0800 Subject: [PATCH 4/4] docs(spp_hazard_programs): regenerate README from CI's generator for the amended 19.0.2.0.1 entry --- spp_hazard_programs/README.rst | 12 +++++++++--- spp_hazard_programs/static/description/index.html | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/spp_hazard_programs/README.rst b/spp_hazard_programs/README.rst index 2d9b09aac..384162d78 100644 --- a/spp_hazard_programs/README.rst +++ b/spp_hazard_programs/README.rst @@ -87,7 +87,7 @@ access from: Extension Points ~~~~~~~~~~~~~~~~ -- Override ``get_emergency_eligible_registrants()`` to customize +- Override ``_get_emergency_eligible_registrants()`` to customize eligibility logic beyond damage levels - Override ``_get_damage_level_domain()`` to add custom damage filtering rules @@ -331,8 +331,14 @@ Changelog 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. + hazard/registry roles. Only aggregate counts are surfaced to program + users without impact read; the list of eligible (impacted) registrants + is the identity linkage the impact ACL protects, so + ``action_view_affected_registrants`` now checks impact read access + server-side, its stat button is gated in the form, and + ``get_emergency_eligible_registrants()`` is renamed + ``_get_emergency_eligible_registrants()`` so it is no longer callable + over RPC (Python callers and overrides are unaffected). 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_hazard_programs/static/description/index.html b/spp_hazard_programs/static/description/index.html index 68329ad5b..9cbb32734 100644 --- a/spp_hazard_programs/static/description/index.html +++ b/spp_hazard_programs/static/description/index.html @@ -447,7 +447,7 @@

    Security

    Extension Points

      -
    • Override get_emergency_eligible_registrants() to customize +
    • Override _get_emergency_eligible_registrants() to customize eligibility logic beyond damage levels
    • Override _get_damage_level_domain() to add custom damage filtering rules
    • @@ -704,8 +704,14 @@

      19.0.2.0.1

      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. +hazard/registry roles. Only aggregate counts are surfaced to program +users without impact read; the list of eligible (impacted) registrants +is the identity linkage the impact ACL protects, so +action_view_affected_registrants now checks impact read access +server-side, its stat button is gated in the form, and +get_emergency_eligible_registrants() is renamed +_get_emergency_eligible_registrants() so it is no longer callable +over RPC (Python callers and overrides are unaffected).