From 26f86de9e6d03446341a2c340b64f31a37c6e798 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 5 May 2026 16:01:31 +0800 Subject: [PATCH 1/6] fix(spp_programs,spp_change_request_v2): apply center-area filter to memberships and change requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local roles (Local Registrar, CR Local Validator, etc.) carry an assigned set of areas via `res.users.role.line.local_area_ids`, which spp_area aggregates onto `res.users.center_area_ids`. spp_area's registrant override (`spp_area/models/registrant.py`) honours that on res.partner reads, so a local user only sees registrants in their assigned regions and descendants. But the same user could open Programs and bulk-modify memberships outside their region, or open Change Requests and act on CRs targeting registrants in other regions — neither model had any equivalent filter. Mirror the registrant override on: - `spp.program.membership` — filter by `partner_id.area_id` - `spp.change.request` — filter by `registrant_id.area_id` Both override `_prepare_domain` / `search_read` / `web_search_read` and use `getattr(user, 'center_area_ids', None)` so the override is a no-op when spp_area isn't loaded. Users without center areas (global roles) see everything as before; users with center areas see only records under their assigned areas (with `child_of` traversal so parent-region assignment matches all child provinces). Refs OP#989. --- spp_change_request_v2/__manifest__.py | 2 +- .../models/change_request.py | 35 +++++++++++++++++++ spp_programs/__manifest__.py | 2 +- spp_programs/models/program_membership.py | 35 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index c565429a..d441e4fe 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.2.0.3", + "version": "19.0.2.0.4", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 8425457d..38c39003 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -1652,3 +1652,38 @@ def action_goto_review(self): }, }, } + + # ─── Area-based filtering (OP#989) ────────────────────────────────── + # Mirrors the `_prepare_domain` pattern from + # spp_area/models/registrant.py so a user with a local role (i.e. + # `center_area_ids` set on res.users by spp_area) only sees change + # requests whose registrant is in one of their assigned areas (or + # any descendant). Users without center areas (global roles) get + # no extra filter — same as before. Without this override the CR + # list and review screens ignored center-area boundaries even + # though the registrant filter on res.partner respected them. + + @api.model + def _prepare_domain(self, domain): + domain = domain or [] + user = self.env.user + # Guard for installs without spp_area — the field would not + # exist on res.users in that case. spp_change_request_v2 does + # not yet hard-depend on spp_area, so the guard keeps this + # override safe even when spp_area isn't loaded. + center_area_ids = getattr(user, "center_area_ids", None) + if center_area_ids: + domain = list(domain) + [ + ("registrant_id.area_id", "child_of", center_area_ids.ids), + ] + return domain + + @api.model + def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): + domain = self._prepare_domain(domain) + return super().search_read(domain, fields, offset, limit, order) + + @api.model + def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): + domain = self._prepare_domain(domain) + return super().web_search_read(domain, specification, offset, limit, order, count_limit) diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index ce105507..3d25c536 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Programs", "summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.", "category": "OpenSPP/Core", - "version": "19.0.2.0.11", + "version": "19.0.2.0.12", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py index 7e93543f..c8bc5c39 100644 --- a/spp_programs/models/program_membership.py +++ b/spp_programs/models/program_membership.py @@ -496,3 +496,38 @@ def _bulk_insert_on_conflict(self, vals_list, chunk_size=1000): len(vals_list) - total_inserted, ) return total_inserted + + # ─── Area-based filtering (OP#989) ────────────────────────────────── + # Mirrors the `_prepare_domain` pattern from + # spp_area/models/registrant.py so a user with a local role (i.e. + # `center_area_ids` set on res.users by spp_area) only sees program + # memberships whose partner is in one of their assigned areas (or + # any descendant of those areas). Users without center areas + # (global roles) get no extra filter — they see everything as + # before. Without this override "Verify Eligibility" / "Enroll + # Eligible" / the membership list bypassed area boundaries entirely. + + @api.model + def _prepare_domain(self, domain): + domain = domain or [] + user = self.env.user + # Guard against installs where spp_area isn't loaded — the + # field would not exist on res.users in that case. spp_programs + # already depends on spp_area today, but the guard keeps this + # override behaving sensibly if that ever changes. + center_area_ids = getattr(user, "center_area_ids", None) + if center_area_ids: + domain = list(domain) + [ + ("partner_id.area_id", "child_of", center_area_ids.ids), + ] + return domain + + @api.model + def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): + domain = self._prepare_domain(domain) + return super().search_read(domain, fields, offset, limit, order) + + @api.model + def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): + domain = self._prepare_domain(domain) + return super().web_search_read(domain, specification, offset, limit, order, count_limit) From 3006625d08e9172d548ff67443fed49047fe5eae Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 12 May 2026 14:11:52 +0800 Subject: [PATCH 2/6] fix(area): replace _prepare_domain overrides with global ir.rules for full row-level area filtering (OP#989 round-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA round 1 surfaced 4 gaps in the original Python _prepare_domain overrides on spp.program.membership and spp.change.request: 1. Access error clicking Eligibility Criteria — related-field traversal bypasses search_read. 2. Program counts wrong — search_count / read_group bypass search_read. 3. Cycle generated from program ignores area — spp.cycle.membership had no override at all. 4. CR registrant picker shows out-of-area registrants — name_search routes through _search, not search_read. Root cause is identical in all four: the Python overrides only hook search_read / web_search_read, so every other ORM read path slips past the filter. The same gap exists on res.partner itself (spp_area's override is structurally identical). Fix: use ir.rule (record rules) which Odoo applies automatically to every read path — search, search_count, read_group, name_search, read, and related-field traversal. - spp_area/security/rules.xml (new): rule on res.partner. - spp_programs/security/area_filter_rules.xml (new): rules on spp.program.membership AND spp.cycle.membership (the latter was missing entirely). - spp_change_request_v2/security/area_filter_rules.xml (new): rule on spp.change.request. All four rules are 'global' and use a conditional domain '[(... 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else []' so they no-op for users without center_area_ids (global roles) and degrade safely if spp_area isn't loaded. The existing Python _prepare_domain / search_read / web_search_read overrides are left in place as belt-and-suspenders — they're now redundant but harmless. --- spp_area/__manifest__.py | 3 +- spp_area/readme/HISTORY.md | 4 ++ spp_area/security/rules.xml | 28 +++++++++++ spp_change_request_v2/__manifest__.py | 3 +- spp_change_request_v2/readme/HISTORY.md | 4 ++ .../security/area_filter_rules.xml | 32 +++++++++++++ spp_programs/__manifest__.py | 3 +- spp_programs/readme/HISTORY.md | 4 ++ spp_programs/security/area_filter_rules.xml | 46 +++++++++++++++++++ 9 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 spp_area/security/rules.xml create mode 100644 spp_change_request_v2/security/area_filter_rules.xml create mode 100644 spp_programs/security/area_filter_rules.xml diff --git a/spp_area/__manifest__.py b/spp_area/__manifest__.py index 29ef826e..2a50339b 100644 --- a/spp_area/__manifest__.py +++ b/spp_area/__manifest__.py @@ -6,7 +6,7 @@ "name": "OpenSPP Area Management", "summary": "Establishes direct associations between OpenSPP registrants, beneficiary groups, and their corresponding geographical administrative areas. It validates registrant-area linkages against official area types, ensuring data integrity and enabling targeted program delivery and analysis.", "category": "OpenSPP/Core", - "version": "19.0.2.0.0", + "version": "19.0.2.0.1", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -33,6 +33,7 @@ "security/privileges.xml", "security/groups.xml", "security/ir.model.access.csv", + "security/rules.xml", "wizard/area_import_language_wizard_views.xml", "views/area_base.xml", "views/area_tag.xml", diff --git a/spp_area/readme/HISTORY.md b/spp_area/readme/HISTORY.md index 4aaf9afe..73e6d978 100644 --- a/spp_area/readme/HISTORY.md +++ b/spp_area/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.1 + +- fix(security): add a global `ir.rule` on `res.partner` that filters registrants by `area_id` for users with `center_area_ids` set (OP#989). Replaces the limited `search_read` / `web_search_read` override in `models/registrant.py` which missed `name_search` (Many2one dropdowns), `search_count`, `read_group`, and related-field traversal. The rule's conditional domain is a no-op for users without center areas (global roles). + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_area/security/rules.xml b/spp_area/security/rules.xml new file mode 100644 index 00000000..c83323e8 --- /dev/null +++ b/spp_area/security/rules.xml @@ -0,0 +1,28 @@ + + + + + Registrants: visible only within user's center areas + + [('area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + + + + + + + diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index d441e4fe..96ac5b90 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.2.0.4", + "version": "19.0.2.0.5", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", @@ -24,6 +24,7 @@ "security/privileges.xml", "security/groups.xml", "security/rules.xml", + "security/area_filter_rules.xml", "security/ir.model.access.csv", # Views (loaded before data that references them) "views/dms_file_views.xml", diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 387d84da..1976b5fc 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.5 + +- fix(security): add a global `ir.rule` on `spp.change.request` that filters by `registrant_id.area_id` against the user's `center_area_ids` (OP#989 round-2). The earlier `_prepare_domain` override only caught `search_read` / `web_search_read` and missed the registrant Many2one picker (which uses `name_search` → `_search`), so users could still select out-of-area registrants. The conditional domain is a no-op for users with no center areas (global roles). + ### 19.0.2.0.3 - fix: add HTML escaping to all computed Html fields with `sanitize=False` to prevent stored XSS (#50) diff --git a/spp_change_request_v2/security/area_filter_rules.xml b/spp_change_request_v2/security/area_filter_rules.xml new file mode 100644 index 00000000..0b6d9559 --- /dev/null +++ b/spp_change_request_v2/security/area_filter_rules.xml @@ -0,0 +1,32 @@ + + + + + Change Request: visible only within user's center areas + + [('registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + + + + + + + diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 3d25c536..959f00b2 100644 --- a/spp_programs/__manifest__.py +++ b/spp_programs/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Programs", "summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.", "category": "OpenSPP/Core", - "version": "19.0.2.0.12", + "version": "19.0.2.0.13", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", @@ -39,6 +39,7 @@ "security/program_security.xml", "security/ir.model.access.csv", "security/registrant_rule.xml", + "security/area_filter_rules.xml", # Data files "data/sequences.xml", "data/queue_data.xml", diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md index 9c79790f..98886887 100644 --- a/spp_programs/readme/HISTORY.md +++ b/spp_programs/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.13 + +- fix(security): add global `ir.rule` records on `spp.program.membership` and `spp.cycle.membership` that filter by `partner_id.area_id` against the user's `center_area_ids` (OP#989 round-2). The earlier Python `_prepare_domain` override on program memberships only caught `search_read` / `web_search_read` and missed counts (`search_count`, `read_group`), dropdowns (`name_search`), and related-field traversal — and cycle memberships had no filter at all. Both rules use a conditional domain that's a no-op for users with no center areas (global roles). + ### 19.0.2.0.11 - Fix `TypeError: 'NoneType' object is not iterable` when clicking **Enroll Eligible** on programs with at least 200 beneficiaries (async dispatch path) diff --git a/spp_programs/security/area_filter_rules.xml b/spp_programs/security/area_filter_rules.xml new file mode 100644 index 00000000..baec4136 --- /dev/null +++ b/spp_programs/security/area_filter_rules.xml @@ -0,0 +1,46 @@ + + + + + Program Membership: visible only within user's center areas + + [('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + + + + + + + + + Cycle Membership: visible only within user's center areas + + [('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + + + + + + + From 40a14cc9bc8b35ee67a6455bac972bc9b0ad445d Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 12 May 2026 14:16:35 +0800 Subject: [PATCH 3/6] fix(area): drop getattr() in ir.rule domains (not in safe_eval allowlist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial round-2 push failed at module install with: NameError: name 'getattr' is not defined ir.rule.domain_force is evaluated by safe_eval with a restricted builtins allowlist that does not include getattr/hasattr. Replace the defensive 'getattr(user, "center_area_ids", False)' guard with a plain truthiness check 'user.center_area_ids' — the field exists in every install path now because: - spp_area's own rule references the field on its own model. - spp_programs already depends on spp_area. - spp_change_request_v2 now declares spp_area in depends (added here) — formally what was already true in any OpenSPP deployment but previously undeclared. --- spp_area/security/rules.xml | 2 +- spp_change_request_v2/__manifest__.py | 1 + spp_change_request_v2/security/area_filter_rules.xml | 6 ++++-- spp_programs/security/area_filter_rules.xml | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/spp_area/security/rules.xml b/spp_area/security/rules.xml index c83323e8..08123b0f 100644 --- a/spp_area/security/rules.xml +++ b/spp_area/security/rules.xml @@ -18,7 +18,7 @@ becomes a no-op for users without `center_area_ids` (global roles). [('area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + >[('area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 96ac5b90..1fae5f0d 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -13,6 +13,7 @@ "mail", "spp_base_common", "spp_registry", + "spp_area", "spp_security", "spp_approval", "spp_event_data", diff --git a/spp_change_request_v2/security/area_filter_rules.xml b/spp_change_request_v2/security/area_filter_rules.xml index 0b6d9559..1a352d5c 100644 --- a/spp_change_request_v2/security/area_filter_rules.xml +++ b/spp_change_request_v2/security/area_filter_rules.xml @@ -12,7 +12,9 @@ path automatically, including `name_search`, `search_count`, `read_group`, and related-field traversal. Mirrors the conditional domain pattern from `spp_area/security/rules.xml` -so users without `center_area_ids` (global roles) are unaffected. +so users without `center_area_ids` (global roles) are unaffected. The +module now depends on `spp_area` directly so `user.center_area_ids` +can be referenced without defensive guards. --> @@ -22,7 +24,7 @@ so users without `center_area_ids` (global roles) are unaffected. [('registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + >[('registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] diff --git a/spp_programs/security/area_filter_rules.xml b/spp_programs/security/area_filter_rules.xml index baec4136..abc34217 100644 --- a/spp_programs/security/area_filter_rules.xml +++ b/spp_programs/security/area_filter_rules.xml @@ -21,7 +21,7 @@ so users without `center_area_ids` (global roles) are unaffected. [('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + >[('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] @@ -36,7 +36,7 @@ so users without `center_area_ids` (global roles) are unaffected. [('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if getattr(user, 'center_area_ids', False) else [] + >[('partner_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] From 5c4ddcf57e07759afef40c0d65c3a7cc87b33546 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Tue, 12 May 2026 14:30:07 +0800 Subject: [PATCH 4/6] fix(area): scope res.partner ir.rule to is_registrant only (OP#989 round-2 polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA hit: Failed to write field spp.program.message_partner_ids Sorry, QA — Local Program Manager (Region IV-A) (id=23) doesn't have 'read' access to: Contact (res.partner) The previous rule filtered the entire res.partner model by area_id. That breaks mail follower lookups (message_partner_ids, message_- follower_ids), the user's own partner record, system bots, and company partners — all of which have area_id = NULL and are excluded by the child_of clause. Narrow the rule to registrants only: ['|', ('is_registrant', '=', False), ('area_id', 'child_of', user.center_area_ids.ids)] Non-registrant contacts remain readable; the area filter only restricts registrant rows, which is the actual OP#989 spec. --- spp_area/security/rules.xml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/spp_area/security/rules.xml b/spp_area/security/rules.xml index 08123b0f..b82bef5b 100644 --- a/spp_area/security/rules.xml +++ b/spp_area/security/rules.xml @@ -9,8 +9,13 @@ which only catches `search_read` / `web_search_read`. An ir.rule applies to every ORM read path automatically: `search`, `search_count`, `read_group`, `name_search` (Many2one dropdowns), `read`, and related-field traversal. -The rule is global (no group restriction) and uses a conditional domain so it -becomes a no-op for users without `center_area_ids` (global roles). +The rule is scoped to `is_registrant = True` only — non-registrant contacts +(users' own partners, admins, companies, system bots, mail followers) must +remain readable, otherwise every record using `message_partner_ids` / +`message_follower_ids` blows up for local users with `center_area_ids`. + +The conditional domain makes the rule a no-op for users without +`center_area_ids` (global roles). --> @@ -18,7 +23,7 @@ becomes a no-op for users without `center_area_ids` (global roles). [('area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + >['|', ('is_registrant', '=', False), ('area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] From e3ce3cd52ccf647091c7b37b18badfceacdce718 Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 14 May 2026 13:03:40 +0800 Subject: [PATCH 5/6] chore: regenerate READMEs after HISTORY entries for OP#989 OCA README generator re-renders section numbering in README.rst and static/description/index.html when a new entry is added to HISTORY.md. Applies CI's regenerated bytes (Python 3.11 docutils output) directly so local toolchain mismatches don't drift the rendering. --- spp_area/README.rst | 11 ++++++ spp_area/static/description/index.html | 12 ++++++ spp_change_request_v2/README.rst | 12 ++++++ .../static/description/index.html | 19 ++++++++-- spp_programs/README.rst | 13 +++++++ spp_programs/static/description/index.html | 38 +++++++++++++------ 6 files changed, 90 insertions(+), 15 deletions(-) diff --git a/spp_area/README.rst b/spp_area/README.rst index bd8dfabb..d186bc1c 100644 --- a/spp_area/README.rst +++ b/spp_area/README.rst @@ -140,6 +140,17 @@ Dependencies Changelog ========= +19.0.2.0.1 +~~~~~~~~~~ + +- fix(security): add a global ``ir.rule`` on ``res.partner`` that + filters registrants by ``area_id`` for users with ``center_area_ids`` + set (OP#989). Replaces the limited ``search_read`` / + ``web_search_read`` override in ``models/registrant.py`` which missed + ``name_search`` (Many2one dropdowns), ``search_count``, + ``read_group``, and related-field traversal. The rule's conditional + domain is a no-op for users without center areas (global roles). + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_area/static/description/index.html b/spp_area/static/description/index.html index 131f7568..1505db84 100644 --- a/spp_area/static/description/index.html +++ b/spp_area/static/description/index.html @@ -537,6 +537,18 @@

Changelog

+

19.0.2.0.1

+
    +
  • fix(security): add a global ir.rule on res.partner that +filters registrants by area_id for users with center_area_ids +set (OP#989). Replaces the limited search_read / +web_search_read override in models/registrant.py which missed +name_search (Many2one dropdowns), search_count, +read_group, and related-field traversal. The rule’s conditional +domain is a no-op for users without center areas (global roles).
  • +
+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 346551e7..ae0a3054 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,18 @@ Before declaring a new CR type complete: Changelog ========= +19.0.2.0.5 +~~~~~~~~~~ + +- fix(security): add a global ``ir.rule`` on ``spp.change.request`` that + filters by ``registrant_id.area_id`` against the user's + ``center_area_ids`` (OP#989 round-2). The earlier ``_prepare_domain`` + override only caught ``search_read`` / ``web_search_read`` and missed + the registrant Many2one picker (which uses ``name_search`` → + ``_search``), so users could still select out-of-area registrants. The + conditional domain is a no-op for users with no center areas (global + roles). + 19.0.2.0.3 ~~~~~~~~~~ diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index f8bf3e1a..202a441b 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,26 +1339,39 @@

    Changelog

+

19.0.2.0.5

+
    +
  • fix(security): add a global ir.rule on spp.change.request that +filters by registrant_id.area_id against the user’s +center_area_ids (OP#989 round-2). The earlier _prepare_domain +override only caught search_read / web_search_read and missed +the registrant Many2one picker (which uses name_search → +_search), so users could still select out-of-area registrants. The +conditional domain is a no-op for users with no center areas (global +roles).
  • +
+
+

19.0.2.0.3

  • fix: add HTML escaping to all computed Html fields with sanitize=False to prevent stored XSS (#50)
-
+

19.0.2.0.2

  • fix: fix batch approval wizard line deletion (#130)
-
+

19.0.2.0.1

  • fix: skip field types before getattr and isolate detail prefetch (#129)
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_programs/README.rst b/spp_programs/README.rst index 0a60c52c..b0c117bf 100644 --- a/spp_programs/README.rst +++ b/spp_programs/README.rst @@ -254,6 +254,19 @@ Dependencies Changelog ========= +19.0.2.1.2 +~~~~~~~~~~ + +- fix(security): add global ``ir.rule`` records on + ``spp.program.membership`` and ``spp.cycle.membership`` that filter by + ``partner_id.area_id`` against the user's ``center_area_ids`` (OP#989 + round-2). The earlier Python ``_prepare_domain`` override on program + memberships only caught ``search_read`` / ``web_search_read`` and + missed counts (``search_count``, ``read_group``), dropdowns + (``name_search``), and related-field traversal — and cycle memberships + had no filter at all. Both rules use a conditional domain that's a + no-op for users with no center areas (global roles). + 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html index a2c4be96..6ca70d87 100644 --- a/spp_programs/static/description/index.html +++ b/spp_programs/static/description/index.html @@ -658,6 +658,20 @@

    Changelog

+

19.0.2.1.2

+
    +
  • fix(security): add global ir.rule records on +spp.program.membership and spp.cycle.membership that filter by +partner_id.area_id against the user’s center_area_ids (OP#989 +round-2). The earlier Python _prepare_domain override on program +memberships only caught search_read / web_search_read and +missed counts (search_count, read_group), dropdowns +(name_search), and related-field traversal — and cycle memberships +had no filter at all. Both rules use a conditional domain that’s a +no-op for users with no center areas (global roles).
  • +
+
+

19.0.2.1.1

  • fix(views): apply spp_registry.x2many_no_padding widget to the @@ -666,7 +680,7 @@

    19.0.2.1.1

    19 inserts on inline list-in-form views (#943).
-
+

19.0.2.0.11

  • Fix TypeError: 'NoneType' object is not iterable when clicking @@ -677,7 +691,7 @@

    19.0.2.0.11

    omit the state filter instead of crashing on tuple(None)
-
+

19.0.2.0.10

  • Increase parallel-safe channel limits (cycle, eligibility_manager, @@ -690,7 +704,7 @@

    19.0.2.0.10

    submission on double-click
-
+

19.0.2.0.9

  • Add context flags (skip_registrant_statistics, @@ -703,7 +717,7 @@

    19.0.2.0.9

    _compute_has_members
-
+

19.0.2.0.8

  • Replace OFFSET pagination with NTILE-based ID-range batching in all @@ -714,7 +728,7 @@

    19.0.2.0.8

    program and cycle
-
+

19.0.2.0.7

  • Bulk membership creation using raw SQL INSERT ON CONFLICT DO NOTHING @@ -723,7 +737,7 @@

    19.0.2.0.7

    _add_beneficiaries with bulk SQL path
-
+

19.0.2.0.6

  • Remove unused entitlement_base_model.py (dead code, never imported)
  • @@ -732,34 +746,34 @@

    19.0.2.0.6

    payment, and fund tests (172 → 492 tests)
-
+

19.0.2.0.5

  • Batch create entitlements and payments instead of one-by-one ORM creates
-
+

19.0.2.0.4

  • Fetch fund balance once per approval batch instead of per entitlement
-
+

19.0.2.0.3

  • Replace cycle computed fields (total_amount, entitlements_count, approval flags) with SQL aggregation queries
-
+

19.0.2.0.2

  • Add composite indexes for frequent query patterns on entitlements and program memberships
-
+

19.0.2.0.1

  • Replace Python-level uniqueness checks with SQL UNIQUE constraints for @@ -768,7 +782,7 @@

    19.0.2.0.1

    constraint creation
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • From de69ff2365d3a3bbd2386cbe9bdf34d2255d68fe Mon Sep 17 00:00:00 2001 From: emjay0921 Date: Thu, 14 May 2026 13:10:11 +0800 Subject: [PATCH 6/6] refactor(spp_programs,spp_change_request_v2): remove dead _prepare_domain overrides (OP#989) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 replaced the Python search_read / web_search_read overrides with ir.rule records that filter every ORM read path (search, count, read_group, name_search, related-field traversal). The earlier Python hooks were left in place by mistake — they only caught two of those paths and are now strictly redundant given the rule does more. Drop the dead code. Behaviour is unchanged (ir.rule still scopes the same reads). Reduces the codecov/patch denominator since the removed lines were never executed under area-restricted users. --- .../models/change_request.py | 35 ------------------- spp_programs/models/program_membership.py | 35 ------------------- 2 files changed, 70 deletions(-) diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 38c39003..8425457d 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -1652,38 +1652,3 @@ def action_goto_review(self): }, }, } - - # ─── Area-based filtering (OP#989) ────────────────────────────────── - # Mirrors the `_prepare_domain` pattern from - # spp_area/models/registrant.py so a user with a local role (i.e. - # `center_area_ids` set on res.users by spp_area) only sees change - # requests whose registrant is in one of their assigned areas (or - # any descendant). Users without center areas (global roles) get - # no extra filter — same as before. Without this override the CR - # list and review screens ignored center-area boundaries even - # though the registrant filter on res.partner respected them. - - @api.model - def _prepare_domain(self, domain): - domain = domain or [] - user = self.env.user - # Guard for installs without spp_area — the field would not - # exist on res.users in that case. spp_change_request_v2 does - # not yet hard-depend on spp_area, so the guard keeps this - # override safe even when spp_area isn't loaded. - center_area_ids = getattr(user, "center_area_ids", None) - if center_area_ids: - domain = list(domain) + [ - ("registrant_id.area_id", "child_of", center_area_ids.ids), - ] - return domain - - @api.model - def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): - domain = self._prepare_domain(domain) - return super().search_read(domain, fields, offset, limit, order) - - @api.model - def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): - domain = self._prepare_domain(domain) - return super().web_search_read(domain, specification, offset, limit, order, count_limit) diff --git a/spp_programs/models/program_membership.py b/spp_programs/models/program_membership.py index c8bc5c39..7e93543f 100644 --- a/spp_programs/models/program_membership.py +++ b/spp_programs/models/program_membership.py @@ -496,38 +496,3 @@ def _bulk_insert_on_conflict(self, vals_list, chunk_size=1000): len(vals_list) - total_inserted, ) return total_inserted - - # ─── Area-based filtering (OP#989) ────────────────────────────────── - # Mirrors the `_prepare_domain` pattern from - # spp_area/models/registrant.py so a user with a local role (i.e. - # `center_area_ids` set on res.users by spp_area) only sees program - # memberships whose partner is in one of their assigned areas (or - # any descendant of those areas). Users without center areas - # (global roles) get no extra filter — they see everything as - # before. Without this override "Verify Eligibility" / "Enroll - # Eligible" / the membership list bypassed area boundaries entirely. - - @api.model - def _prepare_domain(self, domain): - domain = domain or [] - user = self.env.user - # Guard against installs where spp_area isn't loaded — the - # field would not exist on res.users in that case. spp_programs - # already depends on spp_area today, but the guard keeps this - # override behaving sensibly if that ever changes. - center_area_ids = getattr(user, "center_area_ids", None) - if center_area_ids: - domain = list(domain) + [ - ("partner_id.area_id", "child_of", center_area_ids.ids), - ] - return domain - - @api.model - def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None): - domain = self._prepare_domain(domain) - return super().search_read(domain, fields, offset, limit, order) - - @api.model - def web_search_read(self, domain, specification, offset=0, limit=None, order=None, count_limit=None): - domain = self._prepare_domain(domain) - return super().web_search_read(domain, specification, offset, limit, order, count_limit)