From d03e810c19a9d32e024fa707279b9a051a2a5ac9 Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 18:30:19 +0800 Subject: [PATCH 01/18] security(cr): route and apply the same single field for dynamic approvals (#264) Reviewed head: e25070256a3975152ea7ac733d41b4df6e737245 --- spp_change_request_v2/README.rst | 12 ++ spp_change_request_v2/__manifest__.py | 2 +- .../models/change_request.py | 46 ++++++ .../models/change_request_detail_base.py | 66 ++++++++ spp_change_request_v2/readme/HISTORY.md | 4 + .../static/description/index.html | 33 ++-- .../strategies/field_mapping.py | 27 +++- .../tests/test_dynamic_approval.py | 150 +++++++++++++++++- 8 files changed, 324 insertions(+), 16 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 4b687bcd9..5f6c28662 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.3.1.2 +~~~~~~~~~~ + +- fix(security): route and apply the same single field for + dynamic-approval change requests, and freeze the proposed change once + the request leaves draft. The selected field, its old/new values and + the detail pointer were writable after submission, so a requester + could re-route an approval or alter the value that had already been + approved. Note the mapped-source-field freeze applies to + ``field_mapping`` request types; ``custom``-strategy types freeze only + the routing selector. + 19.0.3.1.1 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index cd24a9e22..181d8fec5 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.3.1.1", + "version": "19.0.3.1.2", "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 565185bad..ae12424d0 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -663,6 +663,52 @@ def create(self, vals_list): record._run_conflict_checks() return records + # Fields that bind a submitted CR to exactly what was routed and approved: + # the dynamic-approval selection (synced from the detail's field_to_modify in + # draft) and the detail record pointer that get_detail() resolves for both + # routing and apply. Once the CR leaves draft/revision these are frozen — else + # a user could route on a low-risk field / benign detail and then swap the + # selection or repoint detail_res_id to a substituted detail before apply. + # Editing requires reset to draft, which re-routes. (These fields are never + # written by the apply strategies, so the guard needs no apply-path exemption.) + _FROZEN_ON_SUBMIT_FIELDS = ( + "selected_field_name", + "selected_field_old_value", + "selected_field_new_value", + "detail_res_id", + "detail_res_model", + ) + + @staticmethod + def _normalize_frozen_value(value): + """Normalize a value for change detection: recordset -> id, None -> False. + + Odoo stores unset fields as ``False``, but a write payload (JSON-RPC / + integrations) may pass ``None`` for the same field, or a Many2one as a + recordset. Normalizing both sides prevents an idempotent re-save from + being mistaken for a real change and wrongly locked out. + """ + if hasattr(value, "id"): + value = value.id + return value if value is not None else False + + def write(self, vals): + guarded = [f for f in self._FROZEN_ON_SUBMIT_FIELDS if f in vals] + if guarded: + norm = self._normalize_frozen_value + for rec in self: + if rec.approval_state in ("draft", "revision") or not rec.approval_state: + continue + if any(norm(vals[f]) != norm(rec[f]) for f in guarded): + raise UserError( + _( + "A submitted change request is locked to the change it was " + "routed and approved for; its selected field and detail record " + "cannot be changed. Reset the request to draft to re-route." + ) + ) + return super().write(vals) + def unlink(self): """Delete associated detail records and archive DMS directory.""" directories_to_archive = self.env["spp.dms.directory"] diff --git a/spp_change_request_v2/models/change_request_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py index 6d7313c6c..276727aa5 100644 --- a/spp_change_request_v2/models/change_request_detail_base.py +++ b/spp_change_request_v2/models/change_request_detail_base.py @@ -72,7 +72,73 @@ def _get_field_to_modify_selection(self): """ return [] + def _protected_content_fields(self, change_request): + """Fields whose value defines the proposed change / approval routing. + + These must not change once the CR has left draft/revision, otherwise a + user could re-route the approval (change the selected field) or alter the + value that was routed and approved (see dynamic-approval routing). For + the field_mapping strategy that is the routing selector plus every mapped + source field; apply-output fields (e.g. created_*_id) are NOT included so + the apply strategies can still record their results post-approval. + + SCOPE LIMIT: only ``field_mapping`` types get the mapped-source-field + protection. Types using the ``custom`` apply strategy (add_member, + change_hoh, remove_member, transfer_member, exit_registrant, update_id, + create_group, split_household, merge_registrants) freeze only + ``field_to_modify``, so their detail content fields stay writable after + submission. Closing that needs a per-detail-model override of this + method — tracked separately; do not assume this freeze covers every + change-request type. + """ + protected = {"field_to_modify"} + cr_type = change_request.request_type_id + if cr_type.apply_strategy == "field_mapping": + protected |= {m.source_field for m in cr_type.apply_mapping_ids if m.source_field} + return protected + + @staticmethod + def _normalize_frozen_value(value): + """Normalize a value for change detection: recordset -> id, None -> False. + + Odoo stores unset fields as ``False``, but a write payload may pass + ``None`` for the same field or a Many2one as a recordset; normalizing + both sides prevents an idempotent re-save from being mistaken for a real + change and wrongly locked out. + """ + if hasattr(value, "id"): + value = value.id + return value if value is not None else False + + def _assert_content_editable(self, vals): + """Reject edits to proposed-change fields once the CR is submitted. + + Mirrors the view-level readonly (approval_state not in draft/revision) at + the server so it cannot be bypassed via RPC. Editing requires resetting + the CR to draft, which re-routes the approval. + """ + for rec in self: + change_request = rec.change_request_id + state = change_request.approval_state + if not change_request or state in ("draft", "revision") or not state: + continue + for field_name in rec._protected_content_fields(change_request): + if field_name not in vals or field_name not in rec._fields: + continue + # Normalize both sides (recordset -> id, None -> False) so an + # idempotent re-save, a Many2one written as a recordset, or a + # JSON-RPC None is not mistaken for a real change and locked out. + if self._normalize_frozen_value(vals[field_name]) != self._normalize_frozen_value(rec[field_name]): + raise UserError( + _( + "This change request has already been submitted for approval, " + "so its proposed changes are locked. Reset it to draft to edit " + "(this re-routes the approval)." + ) + ) + def write(self, vals): + self._assert_content_editable(vals) result = super().write(vals) if "field_to_modify" in vals: for rec in self: diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index a87a46557..5f5315699 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.2 + +- fix(security): route and apply the same single field for dynamic-approval change requests, and freeze the proposed change once the request leaves draft. The selected field, its old/new values and the detail pointer were writable after submission, so a requester could re-route an approval or alter the value that had already been approved. Note the mapped-source-field freeze applies to `field_mapping` request types; `custom`-strategy types freeze only the routing selector. + ### 19.0.3.1.1 - fix(change_request): enforce the `(cr_type_id, reason)` uniqueness of per-reason Required-Documents rules with `models.Constraint` (#394). The rule was previously declared via the legacy `_sql_constraints` attribute, which Odoo 19 ignores — the constraint was never created, so duplicate rules for the same reason could be saved silently since 19.0.3.0.0 and one WARNING line was logged on every registry load. A pre-migration removes duplicate rules (the lowest-id rule per pair is kept, matching which rule the runtime applied) so the constraint applies cleanly on upgrade. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index d5ddbc6e7..1756c83d8 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,19 @@

Changelog

+

19.0.3.1.2

+ +
+

19.0.3.1.1

-
+

19.0.3.1.0

  • revert(change_request): restore the create-a-new-individual Add @@ -1370,7 +1383,7 @@

    19.0.3.1.0

    not restored here; reinstate separately if needed.
-
+

19.0.3.0.0

  • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1392,7 +1405,7 @@

    19.0.3.0.0

    must adapt (see #1133).
-
+

19.0.2.0.8

  • fix(views): disable inline creation of CR document types on the Change @@ -1403,7 +1416,7 @@

    19.0.2.0.8

    Documents” modal (missing Name field) that blocked saving (#1125)
-
+

19.0.2.0.7

  • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1415,7 +1428,7 @@

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1430,7 +1443,7 @@

    19.0.2.0.6

    list so row-click goes through the stage router.
-
+

19.0.2.0.5

  • fix(security): add a global ir.rule on spp.change.request that @@ -1443,27 +1456,27 @@

    19.0.2.0.5

    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_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py index a1c795e68..4c09972de 100644 --- a/spp_change_request_v2/strategies/field_mapping.py +++ b/spp_change_request_v2/strategies/field_mapping.py @@ -15,17 +15,35 @@ class SPPCRStrategyFieldMapping(models.AbstractModel): _inherit = "spp.cr.strategy.base" _description = "CR Apply Strategy: Field Mapping" + def _effective_mappings(self, change_request): + """Return the mappings that may be applied for this change request. + + For dynamic-approval CR types the approval workflow is routed and + approved based on a single selected field, so ONLY that field's mapping + may be written to the registrant — regardless of any other mapped detail + fields that were also changed. This keeps the applied change in lockstep + with what was actually approved. Fail closed: if no field is selected, or + the selection maps to no configured field, nothing is applied. + """ + cr_type = change_request.request_type_id + mappings = cr_type.apply_mapping_ids + if not cr_type.use_dynamic_approval: + return mappings + selected = change_request.selected_field_name + if not selected: + return mappings.browse() + return mappings.filtered(lambda m: m.source_field == selected) + def apply(self, change_request): """Apply field mappings from detail to registrant.""" registrant = change_request.registrant_id detail = change_request.get_detail() - cr_type = change_request.request_type_id if not detail: raise UserError(_("No detail record found.")) values = {} - for mapping in cr_type.apply_mapping_ids: + for mapping in self._effective_mappings(change_request): source_value = getattr(detail, mapping.source_field, None) current_value = getattr(registrant, mapping.target_field, None) @@ -147,13 +165,14 @@ def preview(self, change_request): """Preview what changes will be applied.""" registrant = change_request.registrant_id detail = change_request.get_detail() - cr_type = change_request.request_type_id if not detail: return {} changes = {} - for mapping in cr_type.apply_mapping_ids: + # Mirror apply(): a dynamic-approval CR previews only the selected field, + # so the approver sees exactly what will be written. + for mapping in self._effective_mappings(change_request): source_raw = getattr(detail, mapping.source_field, None) current_raw = getattr(registrant, mapping.target_field, None) diff --git a/spp_change_request_v2/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py index 7dfa849b2..ec48c4ec5 100644 --- a/spp_change_request_v2/tests/test_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -14,7 +14,7 @@ import logging from odoo import Command, api -from odoo.exceptions import ValidationError +from odoo.exceptions import UserError, ValidationError from odoo.tests import TransactionCase, tagged _logger = logging.getLogger(__name__) @@ -1057,3 +1057,151 @@ def test_normalize_many2one_with_parent(self): self.assertIn("id", normalized["parent"]) self.assertIn("name", normalized["parent"]) self.assertIn("code", normalized["parent"]) + + # ────────────────────────────────────────────────────────────────────────── + # APPLY RESTRICTION — a dynamic-approval CR must apply ONLY the selected + # field, even if other mapped detail fields were also changed. Otherwise a + # user could route a low-risk field to a weak workflow and smuggle changes + # to other (higher-risk) mapped fields through the same weak approval. + # ────────────────────────────────────────────────────────────────────────── + + def _field_mapping_strategy(self): + return self.env["spp.cr.strategy.field_mapping"] + + def test_dynamic_apply_writes_only_selected_field(self): + cr = self._create_cr() + detail = cr.get_detail() + # Select the low-risk field (phone) but ALSO change a high-risk field. + detail.write({"field_to_modify": "phone", "phone": "999-000", "given_name": "HACKED"}) + self.assertEqual(cr.selected_field_name, "phone") + + self._field_mapping_strategy().apply(cr) + + self.assertEqual(self.registrant.phone, "999-000", "the selected field must be applied") + self.assertEqual( + self.registrant.given_name, + "Original Given", + "a non-selected mapped field must NOT be applied for a dynamic-approval CR", + ) + + def test_dynamic_preview_shows_only_selected_field(self): + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "phone", "phone": "999-000", "given_name": "HACKED"}) + + changes = self._field_mapping_strategy().preview(cr) + + self.assertEqual(len(changes), 1, "preview must show only the selected field for a dynamic CR") + self.assertEqual(next(iter(changes.values()))["new"], "999-000") + + def test_dynamic_apply_unmapped_selected_field_writes_nothing(self): + """Fail-closed: a dynamic CR whose selected field has no mapping writes nothing, + even if another mapped detail field was changed.""" + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"phone": "999-000"}) + # Force a selected field that is not present in apply_mapping_ids. + cr.selected_field_name = "email" + + self._field_mapping_strategy().apply(cr) + + self.assertEqual(self.registrant.phone, "111-222", "nothing may be applied for an unmapped selection") + + def test_non_dynamic_apply_still_writes_all_changed_fields(self): + """Regression: non-dynamic CR types keep applying every changed mapping.""" + nd_type = self.CRType.create( + { + "name": "Non-Dynamic With Mappings", + "code": "nd_with_mappings_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "approval_definition_id": self.static_def.id, + "use_dynamic_approval": False, + "apply_mapping_ids": [ + Command.create({"source_field": "phone", "target_field": "phone", "sequence": 10}), + Command.create({"source_field": "given_name", "target_field": "given_name", "sequence": 20}), + ], + } + ) + reg = self.env["res.partner"].create( + { + "name": "ND Registrant", + "given_name": "OldGiven", + "phone": "000-000", + "is_registrant": True, + "is_group": False, + } + ) + cr = self.CR.create({"request_type_id": nd_type.id, "registrant_id": reg.id}) + detail = cr.get_detail() + detail.write({"phone": "555-555", "given_name": "NewGiven"}) + + self._field_mapping_strategy().apply(cr) + + self.assertEqual(reg.phone, "555-555") + self.assertEqual(reg.given_name, "NewGiven", "non-dynamic CR must apply all changed mappings") + + # ────────────────────────────────────────────────────────────────────────── + # POST-SUBMIT FREEZE — once routed, the proposed change (selected field and + # the mapped values) is frozen. This closes the desync where a user routes on + # a low-risk field, then swaps the field or its value before apply. + # ────────────────────────────────────────────────────────────────────────── + + def _submit_dynamic_cr(self, selected="phone", **detail_vals): + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": selected, selected: detail_vals.get(selected, "999-000"), **detail_vals}) + cr.action_submit_for_approval() + cr.invalidate_recordset() + self.assertEqual(cr.approval_state, "pending") + return cr, detail + + def test_cannot_change_field_to_modify_after_submit(self): + _cr, detail = self._submit_dynamic_cr(selected="phone") + with self.assertRaises(UserError): + detail.write({"field_to_modify": "given_name", "given_name": "HACKED"}) + + def test_cannot_change_selected_field_name_directly_after_submit(self): + cr, _detail = self._submit_dynamic_cr(selected="phone") + with self.assertRaises(UserError): + cr.write({"selected_field_name": "given_name"}) + + def test_cannot_change_selected_field_value_after_submit(self): + """Value-swap: even the same (selected) field's value is frozen post-submit, + because the value was what the approval was routed on.""" + _cr, detail = self._submit_dynamic_cr(selected="phone", phone="111-orig") + with self.assertRaises(UserError): + detail.write({"phone": "222-swapped"}) + + def test_cannot_repoint_detail_after_submit(self): + """Substitution bypass: create a second detail and repoint detail_res_id + to it. get_detail() resolves strictly by detail_res_id, so this would + otherwise apply the substituted values under the original routing.""" + cr, _detail = self._submit_dynamic_cr(selected="phone", phone="111-orig") + substitute = self.env["spp.cr.detail.edit_individual"].create( + {"change_request_id": cr.id, "phone": "999-SUBSTITUTED"} + ) + with self.assertRaises(UserError): + cr.write({"detail_res_id": substitute.id}) + + def test_no_op_write_of_unset_protected_field_is_allowed(self): + """Regression (false-positive lockout): writing None to a protected source + field that is already unset must not raise post-submit. Odoo stores unset + fields as False while a JSON-RPC payload may send None for the same field; + the freeze must treat them as equal (no change), not lock the user out.""" + _cr, detail = self._submit_dynamic_cr(selected="phone") + self.assertFalse(detail.birthdate) # a protected (mapped) field, unset + # None vs the stored False is a no-op, not a change — must not raise. + detail.write({"birthdate": None}) + + def test_can_change_selection_while_draft(self): + """The freeze must not over-block: while still in draft the user can + freely change the selected field (which re-routes on submission).""" + cr = self._create_cr() + detail = cr.get_detail() + detail.write({"field_to_modify": "phone", "phone": "111-222-draft"}) + # Still draft — switching the selected field is allowed. + detail.write({"field_to_modify": "given_name", "given_name": "Draft Edit"}) + self.assertEqual(cr.approval_state, "draft") + self.assertEqual(cr.selected_field_name, "given_name") From f386737bf8ca3eaec4f197f22f29e889f973816b Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 20:50:33 +0800 Subject: [PATCH 02/18] security(cr): add record rules to CR detail models (ownership + area) (#261) Reviewed head: c3f97fca6d804aca86ddddd9ae7691bfdcca2c83 --- spp_change_request_v2/README.rst | 16 + spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 4 + .../security/area_filter_rules.xml | 267 ++++++ spp_change_request_v2/security/rules.xml | 839 ++++++++++++++++++ .../static/description/index.html | 39 +- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_detail_record_rules.py | 220 +++++ spp_cr_type_assign_program/README.rst | 14 +- spp_cr_type_assign_program/__manifest__.py | 3 +- spp_cr_type_assign_program/readme/HISTORY.md | 18 +- spp_cr_type_assign_program/security/rules.xml | 98 ++ .../static/description/index.html | 20 +- spp_cr_type_assign_program/tests/__init__.py | 2 + .../tests/test_detail_security.py | 118 +++ 15 files changed, 1626 insertions(+), 35 deletions(-) create mode 100644 spp_change_request_v2/tests/test_detail_record_rules.py create mode 100644 spp_cr_type_assign_program/security/rules.xml create mode 100644 spp_cr_type_assign_program/tests/test_detail_security.py diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 5f6c28662..bbb71b459 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,22 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.3 +~~~~~~~~~~ + +- fix(security): add ownership and area record rules to every concrete + change-request detail model. Detail rows were reachable by any + ``group_cr_user`` regardless of who owned the parent change request, + so a requester could read or tamper with another user's detail data + over RPC. Each detail model now carries + user/validator/validator-HQ/manager rules scoped through its parent + change request, plus a global rule mirroring the parent's area filter. + ``spp.cr.detail.split_household.member`` is additionally scoped on + delete, the one detail model whose access-control entry grants + ``unlink`` to change-request users: requesters may delete member rows + only on their own requests, while validators and managers keep the + unrestricted delete their access-control entries grant. + 19.0.3.1.2 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 181d8fec5..6210feb57 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.3.1.2", + "version": "19.0.3.1.3", "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/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 5f5315699..6fa08a1a2 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.3 + +- fix(security): add ownership and area record rules to every concrete change-request detail model. Detail rows were reachable by any `group_cr_user` regardless of who owned the parent change request, so a requester could read or tamper with another user's detail data over RPC. Each detail model now carries user/validator/validator-HQ/manager rules scoped through its parent change request, plus a global rule mirroring the parent's area filter. `spp.cr.detail.split_household.member` is additionally scoped on delete, the one detail model whose access-control entry grants `unlink` to change-request users: requesters may delete member rows only on their own requests, while validators and managers keep the unrestricted delete their access-control entries grant. + ### 19.0.3.1.2 - fix(security): route and apply the same single field for dynamic-approval change requests, and freeze the proposed change once the request leaves draft. The selected field, its old/new values and the detail pointer were writable after submission, so a requester could re-route an approval or alter the value that had already been approved. Note the mapped-source-field freeze applies to `field_mapping` request types; `custom`-strategy types freeze only the routing selector. diff --git a/spp_change_request_v2/security/area_filter_rules.xml b/spp_change_request_v2/security/area_filter_rules.xml index 1a352d5c6..f530b6006 100644 --- a/spp_change_request_v2/security/area_filter_rules.xml +++ b/spp_change_request_v2/security/area_filter_rules.xml @@ -31,4 +31,271 @@ can be referenced without defensive guards. + + + + + CR Detail (add_member): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (edit_individual): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (edit_group): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (remove_member): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (change_hoh): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (exit_registrant): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (transfer_member): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (update_id): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (merge_registrants): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (split_household): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (split_household.member): visible only within user's center areas + + [('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group.member_existing): visible only within user's center areas + + [('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group.member_new): visible only within user's center areas + + [('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group.phone): visible only within user's center areas + + ['|', '|', '|', ('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('add_member_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('member_new_id.detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('split_household_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group.bank): visible only within user's center areas + + ['|', '|', '|', ('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('add_member_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('member_new_id.detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('split_household_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + + CR Detail (create_group.id_doc): visible only within user's center areas + + ['|', '|', ('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('add_member_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids), ('split_household_detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + diff --git a/spp_change_request_v2/security/rules.xml b/spp_change_request_v2/security/rules.xml index 8d1675802..6d4dc42a7 100644 --- a/spp_change_request_v2/security/rules.xml +++ b/spp_change_request_v2/security/rules.xml @@ -53,4 +53,843 @@ + + + + + CR Detail (add_member): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (add_member): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (add_member): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (add_member): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_individual): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (edit_individual): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_individual): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_individual): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_group): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (edit_group): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_group): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (edit_group): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (remove_member): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (remove_member): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (remove_member): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (remove_member): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (change_hoh): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (change_hoh): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (change_hoh): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (change_hoh): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (exit_registrant): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (exit_registrant): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (exit_registrant): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (exit_registrant): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (transfer_member): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (transfer_member): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (transfer_member): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (transfer_member): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (update_id): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (update_id): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (update_id): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (update_id): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (create_group): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (create_group): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (create_group): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (create_group): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (merge_registrants): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (merge_registrants): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (merge_registrants): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (merge_registrants): Manager Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (split_household): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + CR Detail (split_household): Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (split_household): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + CR Detail (split_household): Manager Access + + [(1, '=', 1)] + + + + + + + + + + CR Detail (split_household.member): User Access + + [ + '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (split_household.member): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (split_household.member): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (split_household.member): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_existing): User Access + + [ + '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.member_existing): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_existing): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_existing): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_new): User Access + + [ + '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.member_new): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_new): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member_new): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.phone): User Access + + [ + '|', '|', '|', '|', '|', '|', '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('add_member_detail_id.change_request_id.create_uid', '=', user.id), + ('add_member_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('member_new_id.detail_id.change_request_id.create_uid', '=', user.id), + ('member_new_id.detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('split_household_detail_id.change_request_id.create_uid', '=', user.id), + ('split_household_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.phone): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.phone): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.phone): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.bank): User Access + + [ + '|', '|', '|', '|', '|', '|', '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('add_member_detail_id.change_request_id.create_uid', '=', user.id), + ('add_member_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('member_new_id.detail_id.change_request_id.create_uid', '=', user.id), + ('member_new_id.detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('split_household_detail_id.change_request_id.create_uid', '=', user.id), + ('split_household_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.bank): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.bank): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.bank): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.id_doc): User Access + + [ + '|', '|', '|', '|', '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('add_member_detail_id.change_request_id.create_uid', '=', user.id), + ('add_member_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids), + ('split_household_detail_id.change_request_id.create_uid', '=', user.id), + ('split_household_detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.id_doc): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.id_doc): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.id_doc): Manager Access + + [(1, '=', 1)] + + + + + + diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 1756c83d8..56dd4e7ec 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,23 @@

    Changelog

+

19.0.3.1.3

+
    +
  • fix(security): add ownership and area record rules to every concrete +change-request detail model. Detail rows were reachable by any +group_cr_user regardless of who owned the parent change request, +so a requester could read or tamper with another user’s detail data +over RPC. Each detail model now carries +user/validator/validator-HQ/manager rules scoped through its parent +change request, plus a global rule mirroring the parent’s area filter. +spp.cr.detail.split_household.member is additionally scoped on +delete, the one detail model whose access-control entry grants +unlink to change-request users: requesters may delete member rows +only on their own requests, while validators and managers keep the +unrestricted delete their access-control entries grant.
  • +
+
+

19.0.3.1.2

  • fix(security): route and apply the same single field for @@ -1351,7 +1368,7 @@

    19.0.3.1.2

    the routing selector.
-
+

19.0.3.1.1

  • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1365,7 +1382,7 @@

    19.0.3.1.1

    applied) so the constraint applies cleanly on upgrade.
-
+

19.0.3.1.0

  • revert(change_request): restore the create-a-new-individual Add @@ -1383,7 +1400,7 @@

    19.0.3.1.0

    not restored here; reinstate separately if needed.
-
+

19.0.3.0.0

  • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1405,7 +1422,7 @@

    19.0.3.0.0

    must adapt (see #1133).
-
+

19.0.2.0.8

  • fix(views): disable inline creation of CR document types on the Change @@ -1416,7 +1433,7 @@

    19.0.2.0.8

    Documents” modal (missing Name field) that blocked saving (#1125)
-
+

19.0.2.0.7

  • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1428,7 +1445,7 @@

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1443,7 +1460,7 @@

    19.0.2.0.6

    list so row-click goes through the stage router.
-
+

19.0.2.0.5

  • fix(security): add a global ir.rule on spp.change.request that @@ -1456,27 +1473,27 @@

    19.0.2.0.5

    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_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 51588fdeb..193929816 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -26,3 +26,4 @@ from . import test_html_escaping from . import test_wizard_html_escaping from . import test_reason_document_constraint +from . import test_detail_record_rules diff --git a/spp_change_request_v2/tests/test_detail_record_rules.py b/spp_change_request_v2/tests/test_detail_record_rules.py new file mode 100644 index 000000000..f1933d720 --- /dev/null +++ b/spp_change_request_v2/tests/test_detail_record_rules.py @@ -0,0 +1,220 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Security: CR detail models must enforce parent-CR ownership via ir.rule. + +Regression tests for the missing-record-rule vulnerability: a separate detail +model does not inherit the parent ``spp.change.request`` record rules, so a +low-privileged ``group_cr_user`` could read/write detail rows of change +requests they do not own (directly via RPC, bypassing the UI). Each concrete +detail model must ship its own ir.rule mirroring the parent's ownership scope. +""" + +from odoo.exceptions import AccessError +from odoo.tests import tagged + +from .common import CRTestCase, get_or_create_cr_type + + +@tagged("post_install", "-at_install") +class TestDetailRecordRules(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.internal_group = cls.env.ref("base.group_user") + cls.user_group = cls.env.ref("spp_change_request_v2.group_cr_user") + cls.validator_group = cls.env.ref("spp_change_request_v2.group_cr_validator") + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.user_a = Users.create( + { + "name": "CR User A", + "login": "cr_detail_user_a", + "email": "cr_detail_user_a@test.com", + "group_ids": [(4, cls.internal_group.id), (4, cls.user_group.id)], + } + ) + cls.user_b = Users.create( + { + "name": "CR User B", + "login": "cr_detail_user_b", + "email": "cr_detail_user_b@test.com", + "group_ids": [(4, cls.internal_group.id), (4, cls.user_group.id)], + } + ) + cls.validator = Users.create( + { + "name": "CR Validator", + "login": "cr_detail_validator", + "email": "cr_detail_validator@test.com", + "group_ids": [(4, cls.internal_group.id), (4, cls.validator_group.id)], + } + ) + cls.edit_type = get_or_create_cr_type(cls.env, "edit_individual") + + def _make_detail_owned_by(self, user): + """Create a CR (owned by ``user``) and return its detail record.""" + cr = self.CR.with_user(user).create( + { + "request_type_id": self.edit_type.id, + "registrant_id": self.test_individual.id, + } + ) + detail = cr.with_user(user).get_detail() + return cr, detail + + # ------------------------------------------------------------------ + # Completeness: every concrete detail model must be scoped + # ------------------------------------------------------------------ + + def test_every_concrete_detail_model_is_fully_scoped(self): + """Guard against a detail model shipping without complete ownership rules. + + Asserts, for every concrete ``spp.cr.detail.*`` model reachable by + ``group_cr_user`` (via ACL), that ``group_cr_user`` is scoped on EVERY + operation the ACL grants it — read/write/create **and unlink** — a rule + missing only ``perm_write`` would still leave a tamper path, and one + missing ``perm_unlink`` leaves other users' rows deletable — and that + the higher + roles each retain a permissive rule (else the group hierarchy would + cage them behind the restrictive user rule). + + Models the CR user role has NO ACL path to (e.g. the farmer-registry + and studio detail models, gated by their own group models) are out of + scope here: cr_user cannot reach them at all, and their ownership + scoping needs per-module analysis (tracked as a separate follow-up). + """ + models = self.env["ir.model"].search([("model", "=like", "spp.cr.detail.%")]) + self.assertTrue(models, "expected at least one spp.cr.detail.* model") + Access = self.env["ir.model.access"] + Rule = self.env["ir.rule"] + higher_roles = [ + ("validator", self.validator_group), + ("validator_hq", self.env.ref("spp_change_request_v2.group_cr_validator_hq")), + ("manager", self.env.ref("spp_change_request_v2.group_cr_manager")), + ] + problems = [] + checked = 0 + for model in models: + # Transient models (wizards) enforce creator-only access in the + # ORM itself — non-superusers may only reach records they created + # — so they need no ir.rule. + if self.env[model.model]._abstract or self.env[model.model]._transient: + continue + # Skip models cr_user has no ACL path to (global no-group ACLs + # count as a path): in a full-stack DB other apps' detail models + # (different group models, no cr_* ACLs) would otherwise fail + # assertions about a role that cannot touch them anyway. + acls = Access.search( + [("model_id", "=", model.id), "|", ("group_id", "=", False), ("group_id", "=", self.user_group.id)] + ) + if not acls: + continue + checked += 1 + rules = Rule.search([("model_id", "=", model.id)]) + + def grants(group, perm, _rules=rules): + return any(group in r.groups and getattr(r, perm) for r in _rules) + + # Derive the operations to check from what the ACL actually grants, + # so a model shipping an extra permission (e.g. unlink) cannot slip + # through unscoped just because this list was written before it. + for perm in ("perm_read", "perm_write", "perm_create", "perm_unlink"): + if not any(getattr(acl, perm) for acl in acls): + continue + if not grants(self.user_group, perm): + problems.append(f"{model.model}: group_cr_user missing {perm} rule (bypass)") + # Same treatment for the higher roles, and for the same reason: they + # all imply group_cr_user, so if a permission is granted to them by + # ACL but no permissive rule of theirs carries it, the restrictive + # user rule is the only one left and it cages them. Checking only + # read would miss exactly that. + for label, group in higher_roles: + role_acls = Access.search( + [("model_id", "=", model.id), "|", ("group_id", "=", False), ("group_id", "=", group.id)] + ) + for perm in ("perm_read", "perm_write", "perm_create", "perm_unlink"): + if not any(getattr(acl, perm) for acl in role_acls): + continue + if not grants(group, perm): + problems.append( + f"{model.model}: {label} granted {perm} by ACL but no rule carries it " + f"(caged by the restrictive user rule)" + ) + # A global (no-group) read rule mirrors the parent CR area filter. + if not any(not r.groups and r.perm_read for r in rules): + problems.append(f"{model.model}: missing global area-filter rule") + self.assertTrue(checked, "expected at least one cr_user-reachable spp.cr.detail.* model") + self.assertFalse(problems, "detail model rule gaps:\n " + "\n ".join(problems)) + + # ------------------------------------------------------------------ + # Functional: area scoping (mirrors the parent CR area filter) + # ------------------------------------------------------------------ + + def test_area_filter_scopes_detail_by_registrant_area(self): + """An area-scoped user cannot reach details of out-of-area CRs they own. + + Ownership is held constant (the area user creates both CRs while + unrestricted), so this isolates the area dimension: once the user is + restricted to area_1, only the in-area detail remains readable. + """ + Area = self.env["spp.area"] + area_1 = Area.create({"draft_name": "CR Detail Area 1"}) + area_2 = Area.create({"draft_name": "CR Detail Area 2"}) + reg_in = self.Partner.create( + {"name": "Reg In Area", "is_registrant": True, "is_group": False, "area_id": area_1.id} + ) + reg_out = self.Partner.create( + {"name": "Reg Out Area", "is_registrant": True, "is_group": False, "area_id": area_2.id} + ) + # user_a has no center areas yet -> unrestricted create; owns both CRs. + cr_in = self.CR.with_user(self.user_a).create( + {"request_type_id": self.edit_type.id, "registrant_id": reg_in.id} + ) + cr_out = self.CR.with_user(self.user_a).create( + {"request_type_id": self.edit_type.id, "registrant_id": reg_out.id} + ) + detail_in = cr_in.with_user(self.user_a).get_detail() + detail_out = cr_out.with_user(self.user_a).get_detail() + + # Unrestricted (no center areas): both readable — global roles unaffected. + self.assertTrue(detail_out.with_user(self.user_a).read(["change_request_id"])) + + # Restrict user_a to area_1 (center_area_ids is a stored computed field; + # write it directly, after creation, to isolate the area dimension). + self.user_a.sudo().center_area_ids = [(6, 0, [area_1.id])] + self.assertEqual(self.user_a.center_area_ids, area_1) + # ir.rule evaluates and caches its domain per (model, mode); the earlier + # unrestricted read cached an empty domain, so drop the cache to pick up + # the new center-area scope (a real role change invalidates this too). + self.env.registry.clear_cache() + + self.assertTrue(detail_in.with_user(self.user_a).read(["change_request_id"])) + with self.assertRaises(AccessError): + detail_out.with_user(self.user_a).read(["change_request_id"]) + + # ------------------------------------------------------------------ + # Functional: cross-user isolation (edit_individual as a representative) + # ------------------------------------------------------------------ + + def test_cr_user_cannot_read_others_detail(self): + _cr, detail = self._make_detail_owned_by(self.user_a) + # A different cr_user cannot even see it via search. + found = self.env["spp.cr.detail.edit_individual"].with_user(self.user_b).search([("id", "=", detail.id)]) + self.assertFalse(found, "user B must not see user A's detail row") + # Direct read of the known id is denied. + with self.assertRaises(AccessError): + detail.with_user(self.user_b).read(["change_request_id"]) + + def test_cr_user_cannot_write_others_detail(self): + _cr, detail = self._make_detail_owned_by(self.user_a) + # Writing even a same-value field triggers the record-rule check. + with self.assertRaises(AccessError): + detail.with_user(self.user_b).write({"change_request_id": detail.change_request_id.id}) + + def test_cr_user_can_access_own_detail(self): + _cr, detail = self._make_detail_owned_by(self.user_a) + # The owner reads their own detail without error. + self.assertTrue(detail.with_user(self.user_a).read(["change_request_id"])) + + def test_validator_can_access_any_detail(self): + _cr, detail = self._make_detail_owned_by(self.user_a) + # Validators (implying cr_user) retain full visibility, matching the parent CR rule. + self.assertTrue(detail.with_user(self.validator).read(["change_request_id"])) diff --git a/spp_cr_type_assign_program/README.rst b/spp_cr_type_assign_program/README.rst index 6a49d539f..023c341a7 100644 --- a/spp_cr_type_assign_program/README.rst +++ b/spp_cr_type_assign_program/README.rst @@ -91,11 +91,17 @@ Dependencies Changelog ========= -19.0.1.0.0 (2026-05-04) ------------------------ +19.0.1.0.2 +~~~~~~~~~~ -Added -~~~~~ +- fix(security): add record rules to ``spp.cr.detail.assign_program`` + enforcing parent change-request ownership and area scope. The model + previously had an ACL granting ``group_cr_user`` write/create but no + record rule, so a CR user could re-point ``program_id`` on + assign-program details of change requests they do not own via RPC. + +19.0.1.0.0 +~~~~~~~~~~ - New module ``spp_cr_type_assign_program`` with the ``assign_program`` change request type. diff --git a/spp_cr_type_assign_program/__manifest__.py b/spp_cr_type_assign_program/__manifest__.py index f81be529e..01fff2da4 100644 --- a/spp_cr_type_assign_program/__manifest__.py +++ b/spp_cr_type_assign_program/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP CR Type - Assign to Program", - "version": "19.0.1.0.1", + "version": "19.0.1.0.2", "sequence": 53, "category": "OpenSPP", "summary": "Change request type for assigning a registrant to a program", @@ -14,6 +14,7 @@ ], "data": [ "security/ir.model.access.csv", + "security/rules.xml", "views/detail_assign_program_views.xml", "data/cr_types.xml", ], diff --git a/spp_cr_type_assign_program/readme/HISTORY.md b/spp_cr_type_assign_program/readme/HISTORY.md index 74862b003..0809aecb5 100644 --- a/spp_cr_type_assign_program/readme/HISTORY.md +++ b/spp_cr_type_assign_program/readme/HISTORY.md @@ -1,12 +1,10 @@ -## 19.0.1.0.0 (2026-05-04) +### 19.0.1.0.2 -### Added +- fix(security): add record rules to `spp.cr.detail.assign_program` enforcing parent change-request ownership and area scope. The model previously had an ACL granting `group_cr_user` write/create but no record rule, so a CR user could re-point `program_id` on assign-program details of change requests they do not own via RPC. -- New module `spp_cr_type_assign_program` with the `assign_program` change - request type. -- Detail model `spp.cr.detail.assign_program` with live program-domain - filtering based on the registrant's target type. -- Apply strategy `spp.cr.apply.assign_program` that creates a draft - `spp.program.membership` record on apply. -- Conflict rule that blocks duplicate in-flight assignments to the same - `(registrant, program)` pair. +### 19.0.1.0.0 + +- New module `spp_cr_type_assign_program` with the `assign_program` change request type. +- Detail model `spp.cr.detail.assign_program` with live program-domain filtering based on the registrant's target type. +- Apply strategy `spp.cr.apply.assign_program` that creates a draft `spp.program.membership` record on apply. +- Conflict rule that blocks duplicate in-flight assignments to the same `(registrant, program)` pair. diff --git a/spp_cr_type_assign_program/security/rules.xml b/spp_cr_type_assign_program/security/rules.xml new file mode 100644 index 000000000..d6bbe4a5f --- /dev/null +++ b/spp_cr_type_assign_program/security/rules.xml @@ -0,0 +1,98 @@ + + + + + + + CR Detail (assign_program): User Access + + [ + '|', + ('change_request_id.create_uid', '=', user.id), + ('change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + + + CR Detail (assign_program): Validator Access + + [(1, '=', 1)] + + + + + + + + + + CR Detail (assign_program): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + + + CR Detail (assign_program): Manager Access + + [(1, '=', 1)] + + + + + + + + + + CR Detail (assign_program): visible only within user's center areas + + [('change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + diff --git a/spp_cr_type_assign_program/static/description/index.html b/spp_cr_type_assign_program/static/description/index.html index aa6fd572c..97aa7d1af 100644 --- a/spp_cr_type_assign_program/static/description/index.html +++ b/spp_cr_type_assign_program/static/description/index.html @@ -446,21 +446,25 @@

    Dependencies

    Table of contents

    +
    +

    19.0.1.0.2

    +
      +
    • fix(security): add record rules to spp.cr.detail.assign_program +enforcing parent change-request ownership and area scope. The model +previously had an ACL granting group_cr_user write/create but no +record rule, so a CR user could re-point program_id on +assign-program details of change requests they do not own via RPC.
    • +
    -
    -

    Added

    +
    +

    19.0.1.0.0

    • New module spp_cr_type_assign_program with the assign_program change request type.
    • diff --git a/spp_cr_type_assign_program/tests/__init__.py b/spp_cr_type_assign_program/tests/__init__.py index 270981145..32d199be1 100644 --- a/spp_cr_type_assign_program/tests/__init__.py +++ b/spp_cr_type_assign_program/tests/__init__.py @@ -1 +1,3 @@ from . import test_assign_program + +from . import test_detail_security diff --git a/spp_cr_type_assign_program/tests/test_detail_security.py b/spp_cr_type_assign_program/tests/test_detail_security.py new file mode 100644 index 000000000..7c8193ef5 --- /dev/null +++ b/spp_cr_type_assign_program/tests/test_detail_security.py @@ -0,0 +1,118 @@ +"""Security: the assign_program detail must enforce parent-CR ownership. + +Regression test for the reported "Assign-program detail ACL bypasses CR +ownership" issue: without an ir.rule, a low-privileged ``group_cr_user`` could +re-point ``program_id`` on a change request they do not own, enrolling a +beneficiary into an unauthorized program. +""" + +from odoo.exceptions import AccessError +from odoo.tests import tagged + +from odoo.addons.spp_change_request_v2.tests.common import CRTestCase + +ASSIGN_PROGRAM_CR_TYPE_DEFS = { + "name": "Assign to Program", + "target_type": "both", + "detail_model": "spp.cr.detail.assign_program", + "apply_strategy": "custom", + "apply_model": "spp.cr.apply.assign_program", +} + + +@tagged("post_install", "-at_install") +class TestAssignProgramDetailSecurity(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.cr_type = cls.CRType.search([("code", "=", "assign_program")], limit=1) + if not cls.cr_type: + cls.cr_type = cls.CRType.create({"code": "assign_program", **ASSIGN_PROGRAM_CR_TYPE_DEFS}) + + Program = cls.env["spp.program"] + cls.program_a = Program.create({"name": "Program A", "target_type": "individual"}) + cls.program_b = Program.create({"name": "Program B", "target_type": "individual"}) + + cls.internal_group = cls.env.ref("base.group_user") + cls.user_group = cls.env.ref("spp_change_request_v2.group_cr_user") + cls.validator_group = cls.env.ref("spp_change_request_v2.group_cr_validator") + # Assign-program details validate that the writer can actually see the + # target program, and that check runs a search in the writing user's + # own context. Without read access to spp.program the search raises + # AccessError before the validation can even evaluate, which would make + # these fixtures fail for a reason unrelated to the record rules under + # test. Tier-3 programs viewer grants exactly that read. + cls.programs_viewer_group = cls.env.ref("spp_programs.group_programs_viewer") + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.user_a = Users.create( + { + "name": "Assign User A", + "login": "assign_user_a", + "email": "assign_user_a@test.com", + "group_ids": [ + (4, cls.internal_group.id), + (4, cls.user_group.id), + (4, cls.programs_viewer_group.id), + ], + } + ) + cls.user_b = Users.create( + { + "name": "Assign User B", + "login": "assign_user_b", + "email": "assign_user_b@test.com", + "group_ids": [ + (4, cls.internal_group.id), + (4, cls.user_group.id), + (4, cls.programs_viewer_group.id), + ], + } + ) + cls.validator = Users.create( + { + "name": "Assign Validator", + "login": "assign_validator", + "email": "assign_validator@test.com", + "group_ids": [ + (4, cls.internal_group.id), + (4, cls.validator_group.id), + (4, cls.programs_viewer_group.id), + ], + } + ) + + def _make_cr_owned_by(self, user, program=None): + cr = self.CR.with_user(user).create( + { + "request_type_id": self.cr_type.id, + "registrant_id": self.test_individual.id, + } + ) + detail = cr.with_user(user).get_detail() + if program is not None: + detail.with_user(user).program_id = program.id + return cr, detail + + def test_cr_user_cannot_read_others_detail(self): + _cr, detail = self._make_cr_owned_by(self.user_a, self.program_a) + found = self.env["spp.cr.detail.assign_program"].with_user(self.user_b).search([("id", "=", detail.id)]) + self.assertFalse(found, "user B must not see user A's assign-program detail") + with self.assertRaises(AccessError): + detail.with_user(self.user_b).read(["program_id"]) + + def test_cr_user_cannot_repoint_program_on_others_detail(self): + """The exact reported attack: tamper with another user's program assignment.""" + _cr, detail = self._make_cr_owned_by(self.user_a, self.program_a) + with self.assertRaises(AccessError): + detail.with_user(self.user_b).write({"program_id": self.program_b.id}) + # The value is unchanged. + self.assertEqual(detail.program_id, self.program_a) + + def test_owner_can_set_program(self): + _cr, detail = self._make_cr_owned_by(self.user_a) + detail.with_user(self.user_a).write({"program_id": self.program_a.id}) + self.assertEqual(detail.program_id, self.program_a) + + def test_validator_can_read_any_detail(self): + _cr, detail = self._make_cr_owned_by(self.user_a, self.program_a) + self.assertTrue(detail.with_user(self.validator).read(["program_id"])) From 59e9d28f872171ac63efc3c7f15c0d023270f842 Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 21:14:50 +0800 Subject: [PATCH 03/18] security(cr): validate program access on assign-program detail (#338) Reviewed head: e3069bc667c15eca60094ed23b58fe73d38fc983 --- spp_cr_type_assign_program/README.rst | 23 ++ spp_cr_type_assign_program/__manifest__.py | 2 +- .../details/assign_program.py | 37 +++- spp_cr_type_assign_program/readme/HISTORY.md | 20 ++ .../static/description/index.html | 26 ++- .../strategies/assign_program.py | 63 +++++- spp_cr_type_assign_program/tests/__init__.py | 2 +- .../tests/test_program_access.py | 201 ++++++++++++++++++ 8 files changed, 367 insertions(+), 7 deletions(-) create mode 100644 spp_cr_type_assign_program/tests/test_program_access.py diff --git a/spp_cr_type_assign_program/README.rst b/spp_cr_type_assign_program/README.rst index 023c341a7..1b0e23438 100644 --- a/spp_cr_type_assign_program/README.rst +++ b/spp_cr_type_assign_program/README.rst @@ -91,6 +91,29 @@ Dependencies Changelog ========= +19.0.1.0.3 +~~~~~~~~~~ + +- fix(security): validate server-side that the user selecting a program + on ``spp.cr.detail.assign_program`` can actually access it. The + ``program_id`` domain only constrained the UI, so a raw RPC write + could target a hidden or cross-company program; on apply the strategy + runs under ``sudo``, which would assign the membership and leak the + program name via preview while bypassing program record rules and + multi-company scope. An ``@api.constrains`` now rejects a program the + writing user cannot see (record rules) or that is outside their + company scope. +- fix(security): re-assert program access at the apply sink (defense in + depth). The write-time constraint cannot cover a value it never saw — + a record written before the constraint shipped (the module is in + released tags), an import, or a future sudo prefill. The apply + strategy now re-checks the program against the change-request + requester's company scope before creating the membership, so a + pre-existing out-of-scope ``program_id`` cannot be applied + cross-company, and ``preview()`` (which runs under sudo) redacts the + program name for such a record rather than leaking it. No-op in + single-company deployments. + 19.0.1.0.2 ~~~~~~~~~~ diff --git a/spp_cr_type_assign_program/__manifest__.py b/spp_cr_type_assign_program/__manifest__.py index 01fff2da4..8aa59f908 100644 --- a/spp_cr_type_assign_program/__manifest__.py +++ b/spp_cr_type_assign_program/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP CR Type - Assign to Program", - "version": "19.0.1.0.2", + "version": "19.0.1.0.3", "sequence": 53, "category": "OpenSPP", "summary": "Change request type for assigning a registrant to a program", diff --git a/spp_cr_type_assign_program/details/assign_program.py b/spp_cr_type_assign_program/details/assign_program.py index da30dade5..c9af590ae 100644 --- a/spp_cr_type_assign_program/details/assign_program.py +++ b/spp_cr_type_assign_program/details/assign_program.py @@ -1,4 +1,5 @@ -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError class SPPCRDetailAssignProgram(models.Model): @@ -35,6 +36,40 @@ class SPPCRDetailAssignProgram(models.Model): readonly=True, ) + @api.constrains("program_id") + def _check_program_access(self): + """Reject a program the selecting user cannot access. + + The `program_id` domain only constrains the UI; a raw ORM/RPC write can + point it at an arbitrary program. Since the apply strategy runs under + `sudo` (spp.change.request._do_apply), an inaccessible program would + otherwise be assigned - and its name leaked via preview - bypassing + program record rules and multi-company scope. Enforce access here, in + the writing user's own context, so the stored value can only ever be a + program that user may target. + + Two checks, because neither alone is sufficient: + - `search()` requires the program to be visible to the user, enforcing + any record rule on `spp.program` (and rejecting a stale/deleted id). + - an explicit `company_id in env.companies` guard enforces multi-company + scope directly. This is load-bearing, not mere defense in depth: the + global multi-company `ir.rule` on `spp.program` is NOT reliably + applied to the search in this write/constraint context (verified by + test - a company-A user's search still returns a company-B program), + so relying on `search()` alone would let a cross-company program + through. The explicit check rejects it deterministically. + """ + for rec in self: + program = rec.program_id + if not program: + continue + # `or` short-circuits: if the record is not visible, program.company_id + # is not read (avoids an AccessError on a rule-hidden record). + if not self.env["spp.program"].search([("id", "=", program.id)]) or ( + program.company_id and program.company_id not in self.env.companies + ): + raise ValidationError(_("You do not have access to the selected program.")) + @api.depends("registrant_id", "registrant_id.is_group") def _compute_registrant_target_type(self): for rec in self: diff --git a/spp_cr_type_assign_program/readme/HISTORY.md b/spp_cr_type_assign_program/readme/HISTORY.md index 0809aecb5..bf0b3ff0b 100644 --- a/spp_cr_type_assign_program/readme/HISTORY.md +++ b/spp_cr_type_assign_program/readme/HISTORY.md @@ -1,3 +1,23 @@ +### 19.0.1.0.3 + +- fix(security): validate server-side that the user selecting a program on + `spp.cr.detail.assign_program` can actually access it. The `program_id` + domain only constrained the UI, so a raw RPC write could target a hidden or + cross-company program; on apply the strategy runs under `sudo`, which would + assign the membership and leak the program name via preview while bypassing + program record rules and multi-company scope. An `@api.constrains` now rejects + a program the writing user cannot see (record rules) or that is outside their + company scope. +- fix(security): re-assert program access at the apply sink (defense in depth). + The write-time constraint cannot cover a value it never saw — a record + written before the constraint shipped (the module is in released tags), an + import, or a future sudo prefill. The apply strategy now re-checks the + program against the change-request requester's company scope before creating + the membership, so a pre-existing out-of-scope `program_id` cannot be applied + cross-company, and `preview()` (which runs under sudo) redacts the program + name for such a record rather than leaking it. No-op in single-company + deployments. + ### 19.0.1.0.2 - fix(security): add record rules to `spp.cr.detail.assign_program` enforcing parent change-request ownership and area scope. The model previously had an ACL granting `group_cr_user` write/create but no record rule, so a CR user could re-point `program_id` on assign-program details of change requests they do not own via RPC. diff --git a/spp_cr_type_assign_program/static/description/index.html b/spp_cr_type_assign_program/static/description/index.html index 97aa7d1af..7c6734540 100644 --- a/spp_cr_type_assign_program/static/description/index.html +++ b/spp_cr_type_assign_program/static/description/index.html @@ -454,6 +454,30 @@

      Changelog

    +

    19.0.1.0.3

    +
      +
    • fix(security): validate server-side that the user selecting a program +on spp.cr.detail.assign_program can actually access it. The +program_id domain only constrained the UI, so a raw RPC write +could target a hidden or cross-company program; on apply the strategy +runs under sudo, which would assign the membership and leak the +program name via preview while bypassing program record rules and +multi-company scope. An @api.constrains now rejects a program the +writing user cannot see (record rules) or that is outside their +company scope.
    • +
    • fix(security): re-assert program access at the apply sink (defense in +depth). The write-time constraint cannot cover a value it never saw — +a record written before the constraint shipped (the module is in +released tags), an import, or a future sudo prefill. The apply +strategy now re-checks the program against the change-request +requester’s company scope before creating the membership, so a +pre-existing out-of-scope program_id cannot be applied +cross-company, and preview() (which runs under sudo) redacts the +program name for such a record rather than leaking it. No-op in +single-company deployments.
    • +
    +
    +

    19.0.1.0.2

    • fix(security): add record rules to spp.cr.detail.assign_program @@ -463,7 +487,7 @@

      19.0.1.0.2

      assign-program details of change requests they do not own via RPC.
    -
    +

    19.0.1.0.0

    • New module spp_cr_type_assign_program with the assign_program diff --git a/spp_cr_type_assign_program/strategies/assign_program.py b/spp_cr_type_assign_program/strategies/assign_program.py index 4f2fb11c0..b625be798 100644 --- a/spp_cr_type_assign_program/strategies/assign_program.py +++ b/spp_cr_type_assign_program/strategies/assign_program.py @@ -21,8 +21,46 @@ class SPPCRApplyAssignProgram(models.AbstractModel): _inherit = "spp.cr.strategy.base" _description = "CR Apply: Assign to Program" + def _program_accessible_to_requester(self, change_request, program): + """Whether the CR *requester* (``create_uid``) may target ``program``. + + Bound to the requester - the identity whose authority the assignment + rides on - not the apply-time actor, which is sudo (and, after the + apply-authorization guard, a manager who may span companies). Mirrors + the write-time ``_check_program_access`` company scope. Returns True for + a company-shared program (``company_id`` False). No-op in single-company + deployments (every program's company is in every user's ``company_ids``). + """ + requester = change_request.create_uid + return not program.company_id or program.company_id in requester.company_ids + + def _check_program_access_at_apply(self, change_request, program): + """Raise unless the requester may target ``program``. + + Re-asserts access at the sudo sink, so a program written before the + write-time constraint existed (the module shipped in released tags + without it), imported, or slipped in under a sudo prefill, still cannot + be applied cross-company. + """ + if not self._program_accessible_to_requester(change_request, program): + raise UserError( + _("The change request creator does not have access to program '%(program)s'.") + % {"program": program.display_name} + ) + def validate(self, change_request): - """Validate the CR can be applied. Raises UserError on any failure.""" + """Validate the CR can be applied. Raises UserError on any failure. + + This runs under ``sudo`` (see ``spp.change.request._do_apply``). Program + access is enforced primarily at selection time by + ``spp.cr.detail.assign_program._check_program_access`` (a write-time + constraint in the user's own context), but that constraint cannot cover + a stored value it never saw: records written before the constraint + existed (the module shipped in released tags without it), an import, or + a future sudo prefill that sets ``program_id`` without triggering + constraints. So re-assert program access here, at the sink, before the + privileged membership create - see ``_check_program_access_at_apply``. + """ detail = change_request.get_detail() if not detail: raise UserError(_("No detail record found for this change request.")) @@ -31,6 +69,8 @@ def validate(self, change_request): if not program: raise UserError(_("Program is required to assign a registrant.")) + self._check_program_access_at_apply(change_request, program) + registrant = change_request.registrant_id if not registrant: raise UserError(_("Registrant is required.")) @@ -109,14 +149,31 @@ def apply(self, change_request): return True def preview(self, change_request): - """Preview what will happen on apply.""" + """Preview what will happen on apply. + + Runs under sudo (via ``_capture_preview_snapshot`` / the preview HTML), + so redact the program name for a stored program the requester cannot + access - otherwise a pre-existing out-of-scope ``program_id`` (see + ``_check_program_access_at_apply``) would leak the cross-company + program's name here even though it can never be applied. Preview must + stay non-throwing, so redact rather than raise. + """ detail = change_request.get_detail() if not detail or not detail.program_id: return {} + program = detail.program_id + if not self._program_accessible_to_requester(change_request, program): + return { + "_action": "create_program_membership", + "registrant": change_request.registrant_id.display_name, + "program": _("(program not accessible to the requester)"), + "initial_state": "draft", + } + return { "_action": "create_program_membership", "registrant": change_request.registrant_id.display_name, - "program": detail.program_id.display_name, + "program": program.display_name, "initial_state": "draft", } diff --git a/spp_cr_type_assign_program/tests/__init__.py b/spp_cr_type_assign_program/tests/__init__.py index 32d199be1..187bed209 100644 --- a/spp_cr_type_assign_program/tests/__init__.py +++ b/spp_cr_type_assign_program/tests/__init__.py @@ -1,3 +1,3 @@ from . import test_assign_program - from . import test_detail_security +from . import test_program_access diff --git a/spp_cr_type_assign_program/tests/test_program_access.py b/spp_cr_type_assign_program/tests/test_program_access.py new file mode 100644 index 000000000..6292bcdaf --- /dev/null +++ b/spp_cr_type_assign_program/tests/test_program_access.py @@ -0,0 +1,201 @@ +"""Security regression: the assign-program detail must reject a program the +selecting user cannot access. + +The detail's ``program_id`` Many2one ``domain`` only constrains the UI; a raw +ORM/RPC write can point it at an arbitrary program. On apply the strategy runs +under ``sudo`` (`spp.change.request._do_apply`), so program record rules and the +global multi-company rule on ``spp.program`` would be bypassed — assigning a +registrant to a hidden/cross-company program and leaking its name via preview. +An ``@api.constrains`` on the detail enforces, in the writing user's own +context, that the selected program is actually visible to them. +""" + +from odoo.exceptions import UserError, ValidationError +from odoo.tests import tagged + +from odoo.addons.spp_change_request_v2.tests.common import CRTestCase + +ASSIGN_PROGRAM_CR_TYPE_DEFS = { + "name": "Assign to Program", + "target_type": "both", + "detail_model": "spp.cr.detail.assign_program", + "apply_strategy": "custom", + "apply_model": "spp.cr.apply.assign_program", +} + + +@tagged("post_install", "-at_install") +class TestAssignProgramAccess(CRTestCase): + """A CR user must not be able to target a program they cannot access.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Program = cls.env["spp.program"] + + cls.cr_type = cls.CRType.search([("code", "=", "assign_program")], limit=1) + if not cls.cr_type: + cls.cr_type = cls.CRType.create({"code": "assign_program", **ASSIGN_PROGRAM_CR_TYPE_DEFS}) + + cls.company_a = cls.env.company + cls.company_b = cls.env["res.company"].create({"name": "CR Access Test Company B"}) + + # A program the test user CAN see (their own company). + cls.program_visible = cls.Program.create( + { + "name": "Company A Individual Program", + "target_type": "individual", + "company_id": cls.company_a.id, + } + ) + # A program in another company — hidden by the global multi-company rule + # spp_programs.rule_spp_program_company for a company-A-only user. + cls.program_cross_company = cls.Program.create( + { + "name": "Company B Individual Program", + "target_type": "individual", + "company_id": cls.company_b.id, + } + ) + + # A CR user scoped to company A only: can write assign-program details + # (group_cr_user) and read programs (group_programs_viewer), but the + # multi-company rule keeps company-B programs out of their reach. + cls.cr_user = cls.env["res.users"].create( + { + "name": "CR User (Company A)", + "login": "cr_user_company_a", + "company_id": cls.company_a.id, + "company_ids": [(6, 0, [cls.company_a.id])], + "group_ids": [ + ( + 4, + cls.env.ref("spp_change_request_v2.group_cr_user").id, + ), + ( + 4, + cls.env.ref("spp_programs.group_programs_viewer").id, + ), + ], + } + ) + # Make the cr_user's own partner a registrant so change requests can be + # created with it. Using it as the registrant models "the CR user's own + # change request" and keeps these tests robust to detail-ownership record + # rules (PR #261) that scope detail write to CRs the user owns/created. + cls.cr_user_registrant = cls.cr_user.partner_id + cls.cr_user_registrant.write({"is_registrant": True, "is_group": False}) + + def _cr_user_env(self, model): + """`model` in the cr_user's env, scoped to company A only — mirroring a + real company-A session, whose allowed companies are limited to the ones + the user belongs to.""" + return self.env[model].with_user(self.cr_user).with_context(allowed_company_ids=[self.company_a.id]) + + def _make_cr(self): + """Create a CR as admin (CR-name sequence generation needs privileged + access) with the cr_user's own partner as registrant, so a detail write + as cr_user is allowed both today and once PR #261's ownership rule lands. + Returns the CR (admin env).""" + return self.CR.create({"request_type_id": self.cr_type.id, "registrant_id": self.cr_user_registrant.id}) + + def _make_cr_and_detail(self): + """Return (cr, detail) with the detail bound to the cr_user's context — + the program_id write (what the constraint guards) then runs as cr_user.""" + cr = self._make_cr() + detail = cr.get_detail() + return cr, detail.with_user(self.cr_user).with_context(allowed_company_ids=[self.company_a.id]) + + def test_reject_cross_company_program(self): + _cr, detail = self._make_cr_and_detail() + with self.assertRaises(ValidationError): + detail.write({"program_id": self.program_cross_company.id}) + + def test_reject_cross_company_program_on_create(self): + # The constraint must also fire when the program is set at create time. + # Details are created lazily, so create one directly (do not call + # get_detail first, which would auto-create the single detail). + cr = self._make_cr() + with self.assertRaises(ValidationError): + self._cr_user_env("spp.cr.detail.assign_program").create( + { + "change_request_id": cr.id, + "program_id": self.program_cross_company.id, + } + ) + + def test_allow_visible_program(self): + _cr, detail = self._make_cr_and_detail() + detail.write({"program_id": self.program_visible.id}) + self.assertEqual(detail.program_id, self.program_visible) + + def test_allow_shared_program(self): + # A company-shared program (company_id = False) is in no company's + # exclusive scope and must remain selectable. + shared = self.Program.create( + {"name": "Shared Individual Program", "target_type": "individual", "company_id": False} + ) + _cr, detail = self._make_cr_and_detail() + detail.write({"program_id": shared.id}) + self.assertEqual(detail.program_id, shared) + + # --- apply-time sink re-check (defense in depth) ------------------------- + # The write-time constraint above cannot cover a value it never saw: a + # record written before the constraint shipped (the module is in released + # tags 2026.07/2026.08 without it), an import, or a future sudo prefill. + # The strategy re-asserts program access at apply, bound to the CR + # requester's company scope. + + def _plant_poisoned_cr(self, program): + """Simulate a pre-constraint record: a CR whose requester is the + company-A cr_user, carrying `program` on its detail — both written via + direct SQL to bypass the ORM constraint that would reject them today.""" + cr = self._make_cr() + detail = cr.get_detail() + self.env.cr.execute( + "UPDATE spp_change_request SET create_uid = %s WHERE id = %s", + (self.cr_user.id, cr.id), + ) + self.env.cr.execute( + "UPDATE spp_cr_detail_assign_program SET program_id = %s WHERE id = %s", + (program.id, detail.id), + ) + cr.invalidate_recordset(["create_uid"]) + detail.invalidate_recordset(["program_id"]) + return cr + + def test_apply_rejects_preexisting_cross_company_program(self): + """A cross-company program stored before the constraint existed must be + rejected at apply — the sudo strategy no longer trusts the stored value. + Reverting the sink check makes validate() pass this poisoned record.""" + cr = self._plant_poisoned_cr(self.program_cross_company) + with self.assertRaises(UserError): + self.env["spp.cr.apply.assign_program"].validate(cr) + + def test_apply_allows_in_scope_program(self): + """A program within the requester's company passes the sink re-check.""" + cr = self._plant_poisoned_cr(self.program_visible) + # Should not raise on the access check (may still fail later validate() + # rules; assert only the access gate directly). + self.env["spp.cr.apply.assign_program"]._check_program_access_at_apply(cr, self.program_visible) + + def test_apply_allows_shared_program(self): + """A company-shared program (company_id=False) passes the sink re-check.""" + shared = self.Program.create( + {"name": "Shared Program (apply)", "target_type": "individual", "company_id": False} + ) + cr = self._plant_poisoned_cr(shared) + self.env["spp.cr.apply.assign_program"]._check_program_access_at_apply(cr, shared) + + def test_preview_redacts_inaccessible_program(self): + """preview() runs under sudo; for a pre-existing out-of-scope program it + must not leak the program name (which apply would reject anyway).""" + cr = self._plant_poisoned_cr(self.program_cross_company) + preview = self.env["spp.cr.apply.assign_program"].preview(cr) + self.assertNotIn(self.program_cross_company.name, preview.get("program", "")) + + def test_preview_shows_accessible_program(self): + """preview() still shows the program name when the requester can access it.""" + cr = self._plant_poisoned_cr(self.program_visible) + preview = self.env["spp.cr.apply.assign_program"].preview(cr) + self.assertEqual(preview.get("program"), self.program_visible.display_name) From f82a94abfd30e0b9a2c67353c3004b63d062f98a Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 21:26:53 +0800 Subject: [PATCH 04/18] security(programs): enforce system-admin authorization on Force Unlock (#336) Reviewed head: 40f5f873e298dc15e09ff1f6c2eb20cb3761df37 --- spp_farmer_registry_demo/README.rst | 9 + spp_farmer_registry_demo/__manifest__.py | 2 +- .../models/farmer_demo_generator.py | 5 +- spp_farmer_registry_demo/readme/HISTORY.md | 7 + .../static/description/index.html | 14 +- spp_program_geofence/README.rst | 12 ++ spp_program_geofence/__manifest__.py | 2 +- .../models/eligibility_manager.py | 5 +- spp_program_geofence/readme/HISTORY.md | 10 + spp_programs/README.rst | 16 ++ spp_programs/__manifest__.py | 2 +- spp_programs/models/cycle.py | 55 +++++- .../models/managers/cycle_manager_base.py | 23 +-- .../models/managers/eligibility_manager.py | 5 +- .../managers/entitlement_manager_base.py | 25 +-- .../managers/entitlement_manager_cash.py | 7 +- .../managers/entitlement_manager_inkind.py | 14 +- .../models/managers/payment_manager.py | 18 +- .../models/managers/program_manager.py | 6 +- spp_programs/models/programs.py | 57 +++++- spp_programs/readme/HISTORY.md | 14 ++ spp_programs/static/description/index.html | 45 +++-- spp_programs/tests/__init__.py | 1 + .../tests/test_async_lock_recovery.py | 4 +- spp_programs/tests/test_force_unlock_authz.py | 181 ++++++++++++++++++ 25 files changed, 438 insertions(+), 101 deletions(-) create mode 100644 spp_programs/tests/test_force_unlock_authz.py diff --git a/spp_farmer_registry_demo/README.rst b/spp_farmer_registry_demo/README.rst index 281dd5018..2b57f6e6f 100644 --- a/spp_farmer_registry_demo/README.rst +++ b/spp_farmer_registry_demo/README.rst @@ -120,6 +120,15 @@ Dependencies Changelog ========= +19.0.2.1.2 +~~~~~~~~~~ + +- fix(demo): release/force the cycle operation lock through the + ``_release_operation_lock`` helper instead of writing ``is_locked`` + directly, so demo generation stays compatible with the + ``spp_programs`` 19.0.2.2.1 guard that restricts direct writes to the + lock fields to system admins. + 19.0.2.1.1 ~~~~~~~~~~ diff --git a/spp_farmer_registry_demo/__manifest__.py b/spp_farmer_registry_demo/__manifest__.py index 3a0fbab2b..f92f36d6c 100644 --- a/spp_farmer_registry_demo/__manifest__.py +++ b/spp_farmer_registry_demo/__manifest__.py @@ -3,7 +3,7 @@ "name": "OpenSPP Farmer Registry Demo", "summary": "Demo generator for Farmer Registry with fixed stories and volume generation", "category": "OpenSPP", - "version": "19.0.2.1.1", + "version": "19.0.2.1.2", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_farmer_registry_demo/models/farmer_demo_generator.py b/spp_farmer_registry_demo/models/farmer_demo_generator.py index e5b963ec1..6cb4bd71a 100644 --- a/spp_farmer_registry_demo/models/farmer_demo_generator.py +++ b/spp_farmer_registry_demo/models/farmer_demo_generator.py @@ -2297,7 +2297,7 @@ def _create_single_cycle(self, program): try: program_beneficiaries = program.get_beneficiaries("enrolled").mapped("partner_id.id") cycle_manager._add_beneficiaries(cycle, program_beneficiaries, "enrolled", do_count=True) - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() _logger.info( "Synced beneficiary import for cycle (cycle_id=%s, count=%s)", cycle.id, @@ -2343,7 +2343,8 @@ def _create_single_cycle(self, program): exc, ) if cycle.state == "draft": - cycle.write({"state": "to_approve", "is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() + cycle.write({"state": "to_approve"}) # Step 4: Approve cycle (to_approve -> approved) try: diff --git a/spp_farmer_registry_demo/readme/HISTORY.md b/spp_farmer_registry_demo/readme/HISTORY.md index f397e16d8..43b602c17 100644 --- a/spp_farmer_registry_demo/readme/HISTORY.md +++ b/spp_farmer_registry_demo/readme/HISTORY.md @@ -1,3 +1,10 @@ +### 19.0.2.1.2 + +- fix(demo): release/force the cycle operation lock through the + `_release_operation_lock` helper instead of writing `is_locked` directly, + so demo generation stays compatible with the `spp_programs` 19.0.2.2.1 + guard that restricts direct writes to the lock fields to system admins. + ### 19.0.2.1.1 - fix(demo): name each farm after its head member and give every member the head's family name so a household reads as one family; farm names and registry IDs stay unique and generation remains seed-deterministic, resolving duplicate farm names and duplicate Tax/National IDs (#1114) diff --git a/spp_farmer_registry_demo/static/description/index.html b/spp_farmer_registry_demo/static/description/index.html index 4e9aa8e7b..c8938bdc1 100644 --- a/spp_farmer_registry_demo/static/description/index.html +++ b/spp_farmer_registry_demo/static/description/index.html @@ -488,6 +488,16 @@

      Changelog

    +

    19.0.2.1.2

    +
      +
    • fix(demo): release/force the cycle operation lock through the +_release_operation_lock helper instead of writing is_locked +directly, so demo generation stays compatible with the +spp_programs 19.0.2.2.1 guard that restricts direct writes to the +lock fields to system admins.
    • +
    +
    +

    19.0.2.1.1

    • fix(demo): name each farm after its head member and give every member @@ -502,7 +512,7 @@

      19.0.2.1.1

      (#1114)
    -
    +

    19.0.2.1.0

    • feat(demo): add GIS + irrigation scenario (FM4) with reservoir + canal @@ -521,7 +531,7 @@

      19.0.2.1.0

      tables and the CR overview
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_program_geofence/README.rst b/spp_program_geofence/README.rst index 3f1140526..6459f0e36 100644 --- a/spp_program_geofence/README.rst +++ b/spp_program_geofence/README.rst @@ -72,6 +72,18 @@ Known Limitations Changelog ========= +19.0.1.0.1 +---------- + +- fix(security): route the async import lock through the operation-lock + helpers so it keeps working under the new ``spp.program`` write guard. + ``spp_programs`` 19.0.2.2.1 restricts direct writes to ``is_locked`` / + ``locked_reason`` to system administrators; the geofence import + acquired and released the lock with plain writes as the initiating + (non-admin) user, which the guard would reject — leaving the program + stuck locked. It now uses ``_acquire_operation_lock`` / + ``_release_operation_lock`` (which ``sudo()``). + 19.0.1.0.0 ---------- diff --git a/spp_program_geofence/__manifest__.py b/spp_program_geofence/__manifest__.py index f678ea818..7575f18b8 100644 --- a/spp_program_geofence/__manifest__.py +++ b/spp_program_geofence/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP Program Geofence", "summary": "Geofence-based geographic targeting for programs using spatial queries.", "category": "OpenSPP", - "version": "19.0.1.0.0", + "version": "19.0.1.0.1", "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", "license": "LGPL-3", diff --git a/spp_program_geofence/models/eligibility_manager.py b/spp_program_geofence/models/eligibility_manager.py index aa053b311..bc5db379d 100644 --- a/spp_program_geofence/models/eligibility_manager.py +++ b/spp_program_geofence/models/eligibility_manager.py @@ -200,7 +200,7 @@ def _import_registrants_async(self, new_beneficiaries, state="draft"): self.ensure_one() program = self.program_id program.message_post(body=_("Import of %s beneficiaries started.") % len(new_beneficiaries)) - program.write({"is_locked": True, "locked_reason": _("Importing beneficiaries")}) + program._acquire_operation_lock(_("Importing beneficiaries")) jobs = [] for i in range(0, len(new_beneficiaries), self.IMPORT_CHUNK_SIZE): @@ -218,8 +218,7 @@ def mark_import_as_done(self): self.ensure_one() self.program_id._compute_eligible_beneficiary_count() self.program_id._compute_beneficiary_count() - self.program_id.is_locked = False - self.program_id.locked_reason = None + self.program_id._release_operation_lock() self.program_id.message_post(body=_("Import finished.")) def _import_registrants(self, new_beneficiaries, state="draft", do_count=False): diff --git a/spp_program_geofence/readme/HISTORY.md b/spp_program_geofence/readme/HISTORY.md index ae25b1472..92c36283f 100644 --- a/spp_program_geofence/readme/HISTORY.md +++ b/spp_program_geofence/readme/HISTORY.md @@ -1,3 +1,13 @@ +## 19.0.1.0.1 + +- fix(security): route the async import lock through the operation-lock + helpers so it keeps working under the new `spp.program` write guard. + `spp_programs` 19.0.2.2.1 restricts direct writes to `is_locked` / + `locked_reason` to system administrators; the geofence import acquired and + released the lock with plain writes as the initiating (non-admin) user, + which the guard would reject — leaving the program stuck locked. It now + uses `_acquire_operation_lock` / `_release_operation_lock` (which `sudo()`). + ## 19.0.1.0.0 - Initial release: geofence-based program targeting and eligibility management (Tier 1 coordinate intersection, Tier 2 area-intersection fallback), program configuration UI, and program creation wizard support. diff --git a/spp_programs/README.rst b/spp_programs/README.rst index c05dac423..5860f62a3 100644 --- a/spp_programs/README.rst +++ b/spp_programs/README.rst @@ -254,6 +254,22 @@ Dependencies Changelog ========= +19.0.2.2.1 +~~~~~~~~~~ + +- fix(security): make the async operation lock a server-side boundary. + The Force Unlock buttons were gated to ``base.group_system`` in the + views, but ``action_force_unlock`` on ``spp.cycle`` / ``spp.program`` + — and direct writes to the ``is_locked`` / ``locked_reason`` fields — + had no server-side check, so any role holding write access (program + officers, managers, cycle approvers) could clear an active operation + lock via RPC while async entitlement / payment / eligibility jobs were + still running. Direct writes to the lock fields now require + ``base.group_system`` (via a ``write()`` guard), the manual + ``action_force_unlock`` override requires the same, and the async + pipeline manages the lock through ``sudo()`` helpers so legitimate + acquire/release from the initiating user keeps working. + 19.0.2.1.3 ~~~~~~~~~~ diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 8dac1cba2..48590c716 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.2.0", + "version": "19.0.2.2.1", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_programs/models/cycle.py b/spp_programs/models/cycle.py index 21b0b2df7..61f093590 100644 --- a/spp_programs/models/cycle.py +++ b/spp_programs/models/cycle.py @@ -1083,14 +1083,67 @@ def _get_related_job_domain(self): related_jobs = jobs.filtered(lambda r: self in r.args[0]) return [("id", "in", related_jobs.ids)] + def write(self, vals): + # ``is_locked`` / ``locked_reason`` form an operation lock protecting + # in-flight async pipelines (entitlement, payment, eligibility). + # Clearing or setting it out of band lets conflicting operations run, + # so direct writes to these fields are restricted to system + # administrators. The pipeline manages the lock through + # ``_acquire_operation_lock`` / ``_release_operation_lock`` (which + # ``sudo()``), and Force Unlock is the admin-only manual override. + if not self.env.su and ("is_locked" in vals or "locked_reason" in vals): + if not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Changing the operation lock is restricted to system " + "administrators. The lock is managed automatically by " + "the async pipeline; use Force Unlock only in an emergency." + ) + ) + return super().write(vals) + + # NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that + # calls them (e.g. the async mark_*_as_done / mark_*_as_failed completion + # callbacks) is an RPC-reachable lock-clearing path that the write() guard + # above does not cover. Closing that needs authorization on those + # callbacks and is tracked separately in issue #337. + def _acquire_operation_lock(self, reason): + """Set the async-operation lock. Written via ``sudo()`` because direct + writes to ``is_locked`` / ``locked_reason`` are restricted to system + administrators (see ``write``); the pipeline runs as the initiating + non-admin user and must bypass that guard for the two lock fields only.""" + # nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped + self.sudo().write({"is_locked": True, "locked_reason": reason}) + + def _release_operation_lock(self): + """Clear the async-operation lock (see ``_acquire_operation_lock``).""" + # nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped + self.sudo().write({"is_locked": False, "locked_reason": False}) + def action_force_unlock(self): - """Manager-only escape hatch: clear a stuck "Operation in progress" lock. + """System-administrator-only escape hatch: clear a stuck "Operation + in progress" lock. Use when an async pipeline (entitlement processing, payment prep, etc.) died without firing its on_done/on_error callback — for example after a hard server restart or before this fix was deployed. Posts an audit line to chatter so admins can see who unstuck the cycle. + + The view button is gated to base.group_system, but object methods are + reachable via RPC regardless of button visibility, so the same + restriction is enforced here: clearing an active operation lock while + jobs may still be running is an emergency control reserved for system + administrators. Trusted server-side sudo() flows are exempt. """ + if not self.env.su and not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Force unlock is restricted to system administrators. It is an " + "emergency control for clearing a stuck operation lock; ask a " + "system administrator to confirm the async job has actually " + "stopped before the lock is cleared." + ) + ) for rec in self: if not rec.is_locked: continue diff --git a/spp_programs/models/managers/cycle_manager_base.py b/spp_programs/models/managers/cycle_manager_base.py index 9176181b7..a32b3bb52 100644 --- a/spp_programs/models/managers/cycle_manager_base.py +++ b/spp_programs/models/managers/cycle_manager_base.py @@ -322,7 +322,7 @@ def mark_import_as_done(self, cycle, msg): :return: """ self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -334,7 +334,7 @@ def mark_import_as_done(self, cycle, msg): def mark_import_as_failed(self, cycle, msg): """Run via on_error() when async beneficiary import fails.""" self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -351,7 +351,7 @@ def mark_prepare_entitlement_as_done(self, cycle, msg): :return: """ self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -363,7 +363,7 @@ def mark_prepare_entitlement_as_done(self, cycle, msg): def mark_prepare_entitlement_as_failed(self, cycle, msg): """Run via on_error() when async entitlement preparation fails.""" self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -379,7 +379,7 @@ def mark_check_eligibility_as_done(self, cycle): :return: """ self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=_("Eligibility check finished.")) except Exception: @@ -391,7 +391,7 @@ def mark_check_eligibility_as_done(self, cycle): def mark_check_eligibility_as_failed(self, cycle): """Run via on_error() when async eligibility check fails.""" self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=_("Eligibility check failed.")) except Exception: @@ -556,7 +556,7 @@ def _check_eligibility_async(self, cycle, beneficiaries_count): self.ensure_one() _logger.debug("Beneficiaries: %s", beneficiaries_count) cycle.message_post(body=_("Eligibility check of %s beneficiaries started.", beneficiaries_count)) - cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"}) + cycle._acquire_operation_lock("Eligibility check of beneficiaries") states = ("draft", "enrolled", "not_eligible") id_ranges = compute_id_ranges( @@ -638,12 +638,7 @@ def prepare_entitlements(self, cycle): def _prepare_entitlements_async(self, cycle, beneficiaries_count): _logger.debug("Prepare entitlement asynchronously") cycle.message_post(body=_("Prepare entitlement for %s beneficiaries started.", beneficiaries_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Prepare entitlement for beneficiaries."), - } - ) + cycle._acquire_operation_lock(_("Prepare entitlement for beneficiaries.")) id_ranges = compute_id_ranges( self.env.cr, @@ -898,7 +893,7 @@ def add_beneficiaries(self, cycle, beneficiaries, state="draft"): def _add_beneficiaries_async(self, cycle, beneficiaries, state): _logger.debug("Adding beneficiaries asynchronously") cycle.message_post(body=f"Import of {len(beneficiaries)} beneficiaries started.") - cycle.write({"is_locked": True, "locked_reason": _("Importing beneficiaries.")}) + cycle._acquire_operation_lock(_("Importing beneficiaries.")) beneficiaries_count = len(beneficiaries) jobs = [] diff --git a/spp_programs/models/managers/eligibility_manager.py b/spp_programs/models/managers/eligibility_manager.py index c93a65e8f..18c04feb6 100644 --- a/spp_programs/models/managers/eligibility_manager.py +++ b/spp_programs/models/managers/eligibility_manager.py @@ -150,7 +150,7 @@ def _import_registrants_async(self, new_beneficiaries, state="draft"): self.ensure_one() program = self.program_id program.message_post(body=f"Import of {len(new_beneficiaries)} beneficiaries started.") - program.write({"is_locked": True, "locked_reason": "Importing beneficiaries"}) + program._acquire_operation_lock("Importing beneficiaries") jobs = [] for i in range(0, len(new_beneficiaries), 10000): @@ -168,8 +168,7 @@ def mark_import_as_done(self): self.ensure_one() self.program_id.refresh_beneficiary_counts() - self.program_id.is_locked = False - self.program_id.locked_reason = None + self.program_id._release_operation_lock() self.program_id.message_post(body=_("Import finished.")) def _import_registrants(self, new_beneficiaries, state="draft", do_count=False): diff --git a/spp_programs/models/managers/entitlement_manager_base.py b/spp_programs/models/managers/entitlement_manager_base.py index 32cd2bb91..80a8bd0ec 100644 --- a/spp_programs/models/managers/entitlement_manager_base.py +++ b/spp_programs/models/managers/entitlement_manager_base.py @@ -79,12 +79,7 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements): entitlements_count, ) ) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Set entitlements to pending validation for cycle."), - } - ) + cycle._acquire_operation_lock(_("Set entitlements to pending validation for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): @@ -133,12 +128,7 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count): """ _logger.debug("Validate entitlements asynchronously") cycle.message_post(body=_("Validate %s entitlements started.", entitlements_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Validate and approve entitlements for cycle."), - } - ) + cycle._acquire_operation_lock(_("Validate and approve entitlements for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): @@ -200,12 +190,7 @@ def _cancel_entitlements_async(self, cycle, entitlements, entitlements_count): """ _logger.debug("Cancel entitlements asynchronously") cycle.message_post(body=_("Cancel %s entitlements started.", entitlements_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Cancel entitlements for cycle."), - } - ) + cycle._acquire_operation_lock(_("Cancel entitlements for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): @@ -242,7 +227,7 @@ def mark_job_as_done(self, cycle, msg): self.ensure_one() # Clear the lock first so a chatter-side failure can't leave the # cycle stuck with "Operation in progress". - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -259,7 +244,7 @@ def mark_job_as_failed(self, cycle, msg): :param msg: A string to be posted in the chatter """ self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: diff --git a/spp_programs/models/managers/entitlement_manager_cash.py b/spp_programs/models/managers/entitlement_manager_cash.py index 2d7504077..ab377bb68 100644 --- a/spp_programs/models/managers/entitlement_manager_cash.py +++ b/spp_programs/models/managers/entitlement_manager_cash.py @@ -317,12 +317,7 @@ def _validate_entitlements_async(self, cycle, entitlements, entitlements_count): """ _logger.debug("Validate entitlements asynchronously") cycle.message_post(body=_("Validate %s entitlements started.", entitlements_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Validate and approve entitlements for cycle."), - } - ) + cycle._acquire_operation_lock(_("Validate and approve entitlements for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): diff --git a/spp_programs/models/managers/entitlement_manager_inkind.py b/spp_programs/models/managers/entitlement_manager_inkind.py index 8b44c6db7..8b72c6211 100644 --- a/spp_programs/models/managers/entitlement_manager_inkind.py +++ b/spp_programs/models/managers/entitlement_manager_inkind.py @@ -214,12 +214,7 @@ def _set_pending_validation_entitlements_async(self, cycle, entitlements_count): entitlements_count, ) ) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Set entitlements to pending validation for cycle."), - } - ) + cycle._acquire_operation_lock(_("Set entitlements to pending validation for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): @@ -319,12 +314,7 @@ def _validate_entitlements_async(self, cycle, entitlements_count): """ _logger.debug("Validate entitlements asynchronously") cycle.message_post(body=_("Validate %s entitlements started.", entitlements_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Validate and approve entitlements for cycle."), - } - ) + cycle._acquire_operation_lock(_("Validate and approve entitlements for cycle.")) jobs = [] for i in range(0, entitlements_count, self.MAX_ROW_JOB_QUEUE): diff --git a/spp_programs/models/managers/payment_manager.py b/spp_programs/models/managers/payment_manager.py index bc59ef053..980adef74 100644 --- a/spp_programs/models/managers/payment_manager.py +++ b/spp_programs/models/managers/payment_manager.py @@ -71,7 +71,7 @@ def mark_job_as_done(self, cycle, msg): :return: """ self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -80,7 +80,7 @@ def mark_job_as_done(self, cycle, msg): def mark_job_as_failed(self, cycle, msg): """Run via on_error() when the async payment pipeline fails.""" self.ensure_one() - cycle.write({"is_locked": False, "locked_reason": False}) + cycle._release_operation_lock() try: cycle.message_post(body=msg) except Exception: @@ -331,12 +331,7 @@ def _prepare_payments(self, cycle, entitlements): def _prepare_payments_async(self, cycle, entitlements, entitlements_count): _logger.debug("Prepare Payments asynchronously") cycle.message_post(body=_("Prepare payments started for %s entitlements.", entitlements_count)) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Prepare payments for entitlements in cycle."), - } - ) + cycle._acquire_operation_lock(_("Prepare payments for entitlements in cycle.")) # Right now this is not divided into subjobs jobs = [ @@ -446,12 +441,7 @@ def _send_payments(self, batches): def _send_payments_async(self, cycle, batches): _logger.debug("Send Payments asynchronously") cycle.message_post(body=_("Send payments started for %s batches.", len(batches))) - cycle.write( - { - "is_locked": True, - "locked_reason": _("Send payments for batches in cycle."), - } - ) + cycle._acquire_operation_lock(_("Send payments for batches in cycle.")) # Right now this is not divided into subjobs jobs = [ diff --git a/spp_programs/models/managers/program_manager.py b/spp_programs/models/managers/program_manager.py index 7622e5f0b..01ba54d70 100644 --- a/spp_programs/models/managers/program_manager.py +++ b/spp_programs/models/managers/program_manager.py @@ -74,7 +74,7 @@ def mark_enroll_eligible_as_done(self): """ self.ensure_one() program = self.program_id - program.write({"is_locked": False, "locked_reason": False}) + program._release_operation_lock() try: program.message_post(body=_("Eligibility check finished.")) except Exception: @@ -88,7 +88,7 @@ def mark_enroll_eligible_as_failed(self): """Run via on_error() when async eligibility enrollment fails.""" self.ensure_one() program = self.program_id - program.write({"is_locked": False, "locked_reason": False}) + program._release_operation_lock() try: program.message_post(body=_("Eligibility check failed.")) except Exception: @@ -204,7 +204,7 @@ def _enroll_eligible_registrants_async(self, states, members_count): _logger.debug("members: %s", members_count) program = self.program_id program.message_post(body=_("Eligibility check of %s beneficiaries started.", members_count)) - program.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"}) + program._acquire_operation_lock("Eligibility check of beneficiaries") if isinstance(states, str): states = [states] diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py index 5841fb332..cd2d73bad 100644 --- a/spp_programs/models/programs.py +++ b/spp_programs/models/programs.py @@ -2,7 +2,7 @@ import logging from odoo import _, api, fields, models -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from . import constants @@ -763,12 +763,65 @@ def _get_related_job_domain(self): related_jobs = jobs.filtered(lambda r: self in r.records.program_id) return [("id", "in", related_jobs.ids)] + def write(self, vals): + # ``is_locked`` / ``locked_reason`` form an operation lock protecting + # in-flight async pipelines (enrollment, eligibility). Clearing or + # setting it out of band lets conflicting operations run, so direct + # writes to these fields are restricted to system administrators. The + # pipeline manages the lock through ``_acquire_operation_lock`` / + # ``_release_operation_lock`` (which ``sudo()``), and Force Unlock is + # the admin-only manual override. + if not self.env.su and ("is_locked" in vals or "locked_reason" in vals): + if not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Changing the operation lock is restricted to system " + "administrators. The lock is managed automatically by " + "the async pipeline; use Force Unlock only in an emergency." + ) + ) + return super().write(vals) + + # NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that + # calls them (e.g. the async mark_*_as_done / mark_*_as_failed completion + # callbacks) is an RPC-reachable lock-clearing path that the write() guard + # above does not cover. Closing that needs authorization on those + # callbacks and is tracked separately in issue #337. + def _acquire_operation_lock(self, reason): + """Set the async-operation lock. Written via ``sudo()`` because direct + writes to ``is_locked`` / ``locked_reason`` are restricted to system + administrators (see ``write``); the pipeline runs as the initiating + non-admin user and must bypass that guard for the two lock fields only.""" + # nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped + self.sudo().write({"is_locked": True, "locked_reason": reason}) + + def _release_operation_lock(self): + """Clear the async-operation lock (see ``_acquire_operation_lock``).""" + # nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped + self.sudo().write({"is_locked": False, "locked_reason": False}) + def action_force_unlock(self): - """Manager-only escape hatch: clear a stuck "Operation in progress" lock. + """System-administrator-only escape hatch: clear a stuck "Operation + in progress" lock. Use when an async pipeline died without firing its on_done/on_error callback. Posts an audit line to chatter for traceability. + + The view button is gated to base.group_system, but object methods are + reachable via RPC regardless of button visibility, so the same + restriction is enforced here: clearing an active operation lock while + jobs may still be running is an emergency control reserved for system + administrators. Trusted server-side sudo() flows are exempt. """ + if not self.env.su and not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Force unlock is restricted to system administrators. It is an " + "emergency control for clearing a stuck operation lock; ask a " + "system administrator to confirm the async job has actually " + "stopped before the lock is cleared." + ) + ) for rec in self: if not rec.is_locked: continue diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md index 826b3233a..1b094a3f1 100644 --- a/spp_programs/readme/HISTORY.md +++ b/spp_programs/readme/HISTORY.md @@ -1,3 +1,17 @@ +### 19.0.2.2.1 + +- fix(security): make the async operation lock a server-side boundary. The + Force Unlock buttons were gated to `base.group_system` in the views, but + `action_force_unlock` on `spp.cycle` / `spp.program` — and direct writes to + the `is_locked` / `locked_reason` fields — had no server-side check, so any + role holding write access (program officers, managers, cycle approvers) + could clear an active operation lock via RPC while async entitlement / + payment / eligibility jobs were still running. Direct writes to the lock + fields now require `base.group_system` (via a `write()` guard), the manual + `action_force_unlock` override requires the same, and the async pipeline + manages the lock through `sudo()` helpers so legitimate acquire/release + from the initiating user keeps working. + ### 19.0.2.1.3 - fix(security): align Program Viewer / Validator / Cycle Approver roles with the OP#951 menu audit — Program Viewer additionally gets `group_registry_viewer` + `group_approval_viewer` (read-only Registry + Approvals access); all three program roles get `group_hazard_viewer` + `group_gis_report_user` so they retain Hazard / GIS Reports visibility once those menu roots are gated. Adds `spp_hazard` and `spp_gis_report` to module dependencies. diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html index 50e35cbb3..b18978828 100644 --- a/spp_programs/static/description/index.html +++ b/spp_programs/static/description/index.html @@ -658,6 +658,23 @@

      Changelog

    +

    19.0.2.2.1

    +
      +
    • fix(security): make the async operation lock a server-side boundary. +The Force Unlock buttons were gated to base.group_system in the +views, but action_force_unlock on spp.cycle / spp.program +— and direct writes to the is_locked / locked_reason fields — +had no server-side check, so any role holding write access (program +officers, managers, cycle approvers) could clear an active operation +lock via RPC while async entitlement / payment / eligibility jobs were +still running. Direct writes to the lock fields now require +base.group_system (via a write() guard), the manual +action_force_unlock override requires the same, and the async +pipeline manages the lock through sudo() helpers so legitimate +acquire/release from the initiating user keeps working.
    • +
    +
    +

    19.0.2.1.3

    • fix(security): align Program Viewer / Validator / Cycle Approver roles @@ -676,7 +693,7 @@

      19.0.2.1.3

      cross-references — only the dedicated top-level menu disappears.
    -
    +

    19.0.2.1.2

    • fix(security): add global ir.rule records on @@ -690,7 +707,7 @@

      19.0.2.1.2

      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 @@ -699,7 +716,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 @@ -710,7 +727,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, @@ -723,7 +740,7 @@

      19.0.2.0.10

      submission on double-click
    -
    +

    19.0.2.0.9

    • Add context flags (skip_registrant_statistics, @@ -736,7 +753,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 @@ -747,7 +764,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 @@ -756,7 +773,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)
    • @@ -765,34 +782,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 @@ -801,7 +818,7 @@

      19.0.2.0.1

      constraint creation
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py index 15dc1cbe6..24988efbe 100644 --- a/spp_programs/tests/__init__.py +++ b/spp_programs/tests/__init__.py @@ -39,6 +39,7 @@ from . import test_concurrency from . import test_manager_summary_formatting from . import test_async_lock_recovery +from . import test_force_unlock_authz from . import test_membership_acl_bypass from . import test_cycle_null_entitlement_approval from . import test_approve_entitlements_program_isolation diff --git a/spp_programs/tests/test_async_lock_recovery.py b/spp_programs/tests/test_async_lock_recovery.py index da6406a05..0855f5a8b 100644 --- a/spp_programs/tests/test_async_lock_recovery.py +++ b/spp_programs/tests/test_async_lock_recovery.py @@ -11,8 +11,8 @@ - the new `mark_*_as_failed` companions clear the lock too - the existing `mark_*_as_done` paths clear the lock first (so a chatter failure can't leave the lock set) -- `action_force_unlock` is a manager-only escape hatch when no callback - fires at all (e.g. server killed mid-operation) +- `action_force_unlock` is a system-administrator-only escape hatch when + no callback fires at all (e.g. server killed mid-operation) """ import uuid diff --git a/spp_programs/tests/test_force_unlock_authz.py b/spp_programs/tests/test_force_unlock_authz.py new file mode 100644 index 000000000..2dda86eb7 --- /dev/null +++ b/spp_programs/tests/test_force_unlock_authz.py @@ -0,0 +1,181 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Server-side authorization for the Force Unlock escape hatch. + +The Force Unlock buttons on the cycle/program forms are gated to +``base.group_system`` in XML, but that only hides the button — Odoo lets +any user reach ``action_force_unlock`` through RPC/``call_kw``. Because +program officers, managers and cycle approvers hold write access on +``spp.program`` / ``spp.cycle``, without a server-side check they could +clear an active operation lock while async entitlement / payment / +eligibility jobs are still running. These tests pin that only system +administrators (and trusted ``sudo()`` flows) may force-unlock. +""" + +import uuid + +from odoo import fields +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase + + +def _new_program(env): + return ( + env["spp.program"] + .with_context(create_default_managers=True) + .create({"name": f"Force Unlock Authz {uuid.uuid4().hex[:8]}"}) + ) + + +def _new_cycle(env, program): + today = fields.Date.today() + return env["spp.cycle"].create( + { + "name": f"Force Unlock Authz Cycle {uuid.uuid4().hex[:8]}", + "program_id": program.id, + "sequence": 1, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + } + ) + + +class TestForceUnlockAuthorization(TransactionCase): + def setUp(self): + super().setUp() + self.program = _new_program(self.env) + self.cycle = _new_cycle(self.env, self.program) + + def _user(login, group_xmlids): + groups = [self.env.ref("base.group_user")] + groups += [self.env.ref(x) for x in group_xmlids] + return self.env["res.users"].create( + { + "name": login, + "login": login, + "group_ids": [(6, 0, [g.id for g in groups])], + } + ) + + self.officer = _user("fu_officer", ["spp_programs.group_programs_officer"]) + self.manager = _user("fu_manager", ["spp_programs.group_programs_manager"]) + self.approver = _user("fu_approver", ["spp_programs.group_programs_cycle_approver"]) + self.system = _user("fu_system", ["base.group_system"]) + + # --- cycle --------------------------------------------------------- + + def _lock_cycle(self): + self.cycle.write({"is_locked": True, "locked_reason": "Import running"}) + + def test_cycle_force_unlock_denied_for_officer(self): + self._lock_cycle() + with self.assertRaises(AccessError): + self.cycle.with_user(self.officer).action_force_unlock() + self.assertTrue(self.cycle.is_locked, "lock must remain set after a denied call") + + def test_cycle_force_unlock_denied_for_manager(self): + self._lock_cycle() + with self.assertRaises(AccessError): + self.cycle.with_user(self.manager).action_force_unlock() + self.assertTrue(self.cycle.is_locked) + + def test_cycle_force_unlock_denied_for_cycle_approver(self): + self._lock_cycle() + with self.assertRaises(AccessError): + self.cycle.with_user(self.approver).action_force_unlock() + self.assertTrue(self.cycle.is_locked) + + def test_cycle_force_unlock_allowed_for_system_admin(self): + self._lock_cycle() + before = len(self.cycle.message_ids) + self.cycle.with_user(self.system).action_force_unlock() + self.assertFalse(self.cycle.is_locked) + self.assertFalse(self.cycle.locked_reason) + self.assertGreater(len(self.cycle.message_ids), before) + + def test_cycle_force_unlock_allowed_via_sudo(self): + """Trusted server-side sudo() flows are not blocked by the guard.""" + self._lock_cycle() + self.cycle.with_user(self.officer).sudo().action_force_unlock() + self.assertFalse(self.cycle.is_locked) + + # --- program ------------------------------------------------------- + + def test_program_force_unlock_denied_for_manager(self): + self.program.write({"is_locked": True, "locked_reason": "Enrollment running"}) + with self.assertRaises(AccessError): + self.program.with_user(self.manager).action_force_unlock() + self.assertTrue(self.program.is_locked) + + def test_program_force_unlock_allowed_for_system_admin(self): + self.program.write({"is_locked": True, "locked_reason": "Enrollment running"}) + self.program.with_user(self.system).action_force_unlock() + self.assertFalse(self.program.is_locked) + self.assertFalse(self.program.locked_reason) + + # --- direct field write (the sink behind action_force_unlock) ------ + + def test_cycle_direct_write_is_locked_denied_for_officer(self): + """Clearing the lock via a direct RPC write must be blocked too, not + just the action_force_unlock button — officers hold write on the model.""" + self._lock_cycle() + with self.assertRaises(AccessError): + self.cycle.with_user(self.officer).write({"is_locked": False, "locked_reason": False}) + self.assertTrue(self.cycle.is_locked) + + def test_cycle_direct_write_is_locked_denied_for_manager(self): + self._lock_cycle() + with self.assertRaises(AccessError): + self.cycle.with_user(self.manager).write({"is_locked": False}) + self.assertTrue(self.cycle.is_locked) + + def test_cycle_direct_write_setting_lock_denied_for_manager(self): + """Setting the lock out of band is blocked as well (availability).""" + with self.assertRaises(AccessError): + self.cycle.with_user(self.manager).write({"is_locked": True, "locked_reason": "x"}) + self.assertFalse(self.cycle.is_locked) + + def test_program_direct_write_is_locked_denied_for_manager(self): + self.program.write({"is_locked": True, "locked_reason": "Enrollment running"}) + with self.assertRaises(AccessError): + self.program.with_user(self.manager).write({"is_locked": False}) + self.assertTrue(self.program.is_locked) + + def test_cycle_direct_write_is_locked_allowed_for_system_admin(self): + self._lock_cycle() + self.cycle.with_user(self.system).write({"is_locked": False, "locked_reason": False}) + self.assertFalse(self.cycle.is_locked) + + def test_manager_editing_other_fields_still_works(self): + """The guard only covers the lock fields — normal edits by a manager + (who holds write) must not be affected.""" + self._lock_cycle() + # A non-lock field write by the manager succeeds even while locked. + self.cycle.with_user(self.manager).write({"name": "Renamed [CYCLE TEST]"}) + self.assertEqual(self.cycle.name, "Renamed [CYCLE TEST]") + + # --- pipeline helpers still work for the non-admin operating user -- + + def test_release_operation_lock_works_for_non_admin(self): + """The async pipeline releases its own lock via the sudo() helper even + though the job runs as the initiating (non-admin) user.""" + self._lock_cycle() + self.cycle.with_user(self.officer)._release_operation_lock() + self.assertFalse(self.cycle.is_locked) + self.assertFalse(self.cycle.locked_reason) + + def test_acquire_operation_lock_works_for_non_admin(self): + self.cycle.with_user(self.officer)._acquire_operation_lock("Import running") + self.assertTrue(self.cycle.is_locked) + self.assertEqual(self.cycle.locked_reason, "Import running") + + def test_eligibility_mark_import_done_releases_lock_as_non_admin(self): + """Regression: the async import on_done callback runs as the initiating + non-admin user; it must release the program lock through the sudo helper + rather than a direct field write that the guard would reject.""" + manager = self.env["spp.program.membership.manager.default"].create( + {"name": "Elig Manager", "program_id": self.program.id} + ) + self.program.write({"is_locked": True, "locked_reason": "Importing beneficiaries"}) + manager.with_user(self.officer).mark_import_as_done() + self.assertFalse(self.program.is_locked) + self.assertFalse(self.program.locked_reason) From c9acdc3a981194b68a45ba7dfc4039bfedc73124 Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 21:52:00 +0800 Subject: [PATCH 05/18] security(roles): scope program/CR roles to Tier-3 registry read (drop registry-search menu) (#353) Reviewed head: acdba200b560b8561cbf6d10cf057afc9abce82f --- spp_change_request_v2/README.rst | 11 +++ spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/data/user_roles.xml | 16 ++++- .../migrations/19.0.3.1.4/post-migration.py | 50 ++++++++++++++ spp_change_request_v2/readme/HISTORY.md | 4 ++ .../static/description/index.html | 36 ++++++---- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_cr_roles_registry_scope.py | 60 +++++++++++++++++ spp_programs/README.rst | 13 ++++ spp_programs/__manifest__.py | 2 +- spp_programs/data/user_roles.xml | 11 ++- .../migrations/19.0.2.2.2/post-migration.py | 45 +++++++++++++ spp_programs/readme/HISTORY.md | 4 ++ spp_programs/static/description/index.html | 44 +++++++----- spp_programs/tests/__init__.py | 1 + .../test_program_viewer_registry_scope.py | 67 +++++++++++++++++++ 16 files changed, 334 insertions(+), 33 deletions(-) create mode 100644 spp_change_request_v2/migrations/19.0.3.1.4/post-migration.py create mode 100644 spp_change_request_v2/tests/test_cr_roles_registry_scope.py create mode 100644 spp_programs/migrations/19.0.2.2.2/post-migration.py create mode 100644 spp_programs/tests/test_program_viewer_registry_scope.py diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index bbb71b459..6830899f3 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,17 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.4 +~~~~~~~~~~ + +- fix(security): scope the CR Requestor, Local Validator and HQ + Validator roles to Tier-3 registry read instead of Tier-2 registry + viewer. The viewer tier gates the Registry Search portal, a broad + registrant-PII enumeration surface these change-request roles do not + need; registrant read access is unchanged. A migration re-points the + roles and resynchronises existing users, since the role definitions + are ``noupdate``. + 19.0.3.1.3 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 6210feb57..ea5dc8537 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.3.1.3", + "version": "19.0.3.1.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/data/user_roles.xml b/spp_change_request_v2/data/user_roles.xml index c8e4e7364..75bc23d54 100644 --- a/spp_change_request_v2/data/user_roles.xml +++ b/spp_change_request_v2/data/user_roles.xml @@ -5,6 +5,16 @@ Part of OpenSPP. See LICENSE file for full copyright and licensing details. User roles for Change Request module. --> + + @@ -18,7 +28,7 @@ User roles for Change Request module. eval="[ Command.link(ref('base.group_user')), Command.link(ref('group_cr_manager')), - Command.link(ref('spp_registry.group_registry_viewer')), + Command.link(ref('spp_registry.group_registry_read')), Command.link(ref('spp_hazard.group_hazard_viewer')), ]" /> @@ -35,7 +45,7 @@ User roles for Change Request module. eval="[ Command.link(ref('base.group_user')), Command.link(ref('group_cr_validator')), - Command.link(ref('spp_registry.group_registry_viewer')), + Command.link(ref('spp_registry.group_registry_read')), Command.link(ref('spp_hazard.group_hazard_viewer')), ]" /> @@ -52,7 +62,7 @@ User roles for Change Request module. eval="[ Command.link(ref('base.group_user')), Command.link(ref('group_cr_validator_hq')), - Command.link(ref('spp_registry.group_registry_viewer')), + Command.link(ref('spp_registry.group_registry_read')), Command.link(ref('spp_hazard.group_hazard_viewer')), ]" /> diff --git a/spp_change_request_v2/migrations/19.0.3.1.4/post-migration.py b/spp_change_request_v2/migrations/19.0.3.1.4/post-migration.py new file mode 100644 index 000000000..af0cbace6 --- /dev/null +++ b/spp_change_request_v2/migrations/19.0.3.1.4/post-migration.py @@ -0,0 +1,50 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Swap the CR roles from Tier-2 ``group_registry_viewer`` to Tier-3 +``group_registry_read``. + +The roles' ``implied_ids`` are seeded from ``data/user_roles.xml`` with +``noupdate="1"``, so a released database (2026.07) keeps the old +``group_registry_viewer`` link on upgrade and would retain the Registry Search +portal menu. This migration unlinks the Tier-2 viewer group, links the Tier-3 +read group (same registrant read ACLs, no menu; read is also provided through +the group_cr_* chain), and re-materializes the group membership of users +already assigned each role. +""" + +import logging + +from odoo import SUPERUSER_ID, Command, api + +_logger = logging.getLogger(__name__) + +_ROLE_XMLIDS = [ + "spp_change_request_v2.global_role_cr_requestor", + "spp_change_request_v2.local_role_cr_validator", + "spp_change_request_v2.global_role_cr_validator_hq", +] + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + viewer = env.ref("spp_registry.group_registry_viewer", raise_if_not_found=False) + read = env.ref("spp_registry.group_registry_read", raise_if_not_found=False) + if not viewer or not read: + return + for xmlid in _ROLE_XMLIDS: + role = env.ref(xmlid, raise_if_not_found=False) + if not role: + continue + commands = [] + if viewer in role.implied_ids: + commands.append(Command.unlink(viewer.id)) + if read not in role.implied_ids: + commands.append(Command.link(read.id)) + if commands: + role.implied_ids = commands + role.action_update_users() + _logger.info( + "Migrated role %s: registry viewer -> registry read (re-synced users)", + xmlid, + ) diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 6fa08a1a2..12a6f1f5f 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.4 + +- fix(security): scope the CR Requestor, Local Validator and HQ Validator roles to Tier-3 registry read instead of Tier-2 registry viewer. The viewer tier gates the Registry Search portal, a broad registrant-PII enumeration surface these change-request roles do not need; registrant read access is unchanged. A migration re-points the roles and resynchronises existing users, since the role definitions are `noupdate`. + ### 19.0.3.1.3 - fix(security): add ownership and area record rules to every concrete change-request detail model. Detail rows were reachable by any `group_cr_user` regardless of who owned the parent change request, so a requester could read or tamper with another user's detail data over RPC. Each detail model now carries user/validator/validator-HQ/manager rules scoped through its parent change request, plus a global rule mirroring the parent's area filter. `spp.cr.detail.split_household.member` is additionally scoped on delete, the one detail model whose access-control entry grants `unlink` to change-request users: requesters may delete member rows only on their own requests, while validators and managers keep the unrestricted delete their access-control entries grant. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 56dd4e7ec..5b03c0fbf 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,18 @@

      Changelog

    +

    19.0.3.1.4

    +
      +
    • fix(security): scope the CR Requestor, Local Validator and HQ +Validator roles to Tier-3 registry read instead of Tier-2 registry +viewer. The viewer tier gates the Registry Search portal, a broad +registrant-PII enumeration surface these change-request roles do not +need; registrant read access is unchanged. A migration re-points the +roles and resynchronises existing users, since the role definitions +are noupdate.
    • +
    +
    +

    19.0.3.1.3

    • fix(security): add ownership and area record rules to every concrete @@ -1355,7 +1367,7 @@

      19.0.3.1.3

      unrestricted delete their access-control entries grant.
    -
    +

    19.0.3.1.2

    • fix(security): route and apply the same single field for @@ -1368,7 +1380,7 @@

      19.0.3.1.2

      the routing selector.
    -
    +

    19.0.3.1.1

    • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1382,7 +1394,7 @@

      19.0.3.1.1

      applied) so the constraint applies cleanly on upgrade.
    -
    +

    19.0.3.1.0

    • revert(change_request): restore the create-a-new-individual Add @@ -1400,7 +1412,7 @@

      19.0.3.1.0

      not restored here; reinstate separately if needed.
    -
    +

    19.0.3.0.0

    • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1422,7 +1434,7 @@

      19.0.3.0.0

      must adapt (see #1133).
    -
    +

    19.0.2.0.8

    • fix(views): disable inline creation of CR document types on the Change @@ -1433,7 +1445,7 @@

      19.0.2.0.8

      Documents” modal (missing Name field) that blocked saving (#1125)
    -
    +

    19.0.2.0.7

    • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1445,7 +1457,7 @@

      19.0.2.0.7

      dependencies.
    -
    +

    19.0.2.0.6

    • fix(views): route post-submit CRs (pending / approved / applied / @@ -1460,7 +1472,7 @@

      19.0.2.0.6

      list so row-click goes through the stage router.
    -
    +

    19.0.2.0.5

    • fix(security): add a global ir.rule on spp.change.request that @@ -1473,27 +1485,27 @@

      19.0.2.0.5

      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_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 193929816..f85ca3317 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -27,3 +27,4 @@ from . import test_wizard_html_escaping from . import test_reason_document_constraint from . import test_detail_record_rules +from . import test_cr_roles_registry_scope diff --git a/spp_change_request_v2/tests/test_cr_roles_registry_scope.py b/spp_change_request_v2/tests/test_cr_roles_registry_scope.py new file mode 100644 index 000000000..43761489e --- /dev/null +++ b/spp_change_request_v2/tests/test_cr_roles_registry_scope.py @@ -0,0 +1,60 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""CR roles must read registrant data (a change request is about a registrant) +but must NOT carry the Tier-2 ``group_registry_viewer`` group, which gates the +standalone Registry Search portal menu — an over-broad registrant PII +enumeration surface. Registrant read is preserved via the Tier-3 +``group_registry_read`` group (granted through their ``group_cr_*`` chain and +the explicit role link). +""" + +from odoo.tests import TransactionCase, tagged + +_CR_ROLE_XMLIDS = [ + "spp_change_request_v2.global_role_cr_requestor", + "spp_change_request_v2.local_role_cr_validator", + "spp_change_request_v2.global_role_cr_validator_hq", +] + + +@tagged("post_install", "-at_install") +class TestCRRolesRegistryScope(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.registrant = cls.env["res.partner"].create( + {"name": "CR Registrant", "is_registrant": True, "is_group": False} + ) + cls.reg_id = cls.env["spp.registry.id"].create( + { + "partner_id": cls.registrant.id, + "id_type_id": cls.env.ref("spp_vocabulary.code_id_type_national_id").id, + "value": "CR-123", + } + ) + cls.phone = cls.env["spp.phone.number"].create({"partner_id": cls.registrant.id, "phone_no": "09180000000"}) + + def _user_with_role(self, role_xmlid, login): + user = self.env["res.users"].create({"name": login, "login": login, "email": f"{login}@example.com"}) + self.env["res.users.role.line"].create({"user_id": user.id, "role_id": self.env.ref(role_xmlid).id}) + user.set_groups_from_roles() + return user + + def test_cr_roles_lack_tier2_registry_viewer(self): + for xmlid in _CR_ROLE_XMLIDS: + user = self._user_with_role(xmlid, f"crscope_{xmlid.split('.')[-1]}") + self.assertFalse( + user.has_group("spp_registry.group_registry_viewer"), + f"{xmlid} must not carry the Tier-2 registry viewer group (it gates the registry search portal menu)", + ) + + def test_cr_roles_keep_registrant_read(self): + for xmlid in _CR_ROLE_XMLIDS: + user = self._user_with_role(xmlid, f"crread_{xmlid.split('.')[-1]}") + self.assertTrue( + user.has_group("spp_registry.group_registry_read"), + f"{xmlid} must keep Tier-3 registry read", + ) + # Functional read of the sensitive PII models as the role user. + self.registrant.with_user(user).read(["name"]) + self.reg_id.with_user(user).read(["value"]) + self.phone.with_user(user).read(["phone_no"]) diff --git a/spp_programs/README.rst b/spp_programs/README.rst index 5860f62a3..eb2737a66 100644 --- a/spp_programs/README.rst +++ b/spp_programs/README.rst @@ -254,6 +254,19 @@ Dependencies Changelog ========= +19.0.2.2.2 +~~~~~~~~~~ + +- fix(security): the Program Viewer role no longer carries the Tier-2 + ``spp_registry.group_registry_viewer`` group, which gates the + standalone Registry Search portal menu and exposed a broad + registrant-PII enumeration surface to a read-only program role. It now + uses the Tier-3 ``spp_registry.group_registry_read`` group instead, + preserving the registrant read needed for program cross-references + (same read ACLs, defined in ``spp_base_common``) without the Registry + app menu. Includes a migration that re-points the role and re-syncs + already-assigned users on upgrade. + 19.0.2.2.1 ~~~~~~~~~~ diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 48590c716..f7c9e330b 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.2.1", + "version": "19.0.2.2.2", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_programs/data/user_roles.xml b/spp_programs/data/user_roles.xml index 62b2275f6..95b6d2642 100644 --- a/spp_programs/data/user_roles.xml +++ b/spp_programs/data/user_roles.xml @@ -7,12 +7,21 @@ Read-only access to program, cycle, and entitlement records. + registry read (re-synced users)", + xmlid, + ) diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md index 1b094a3f1..962e2f55f 100644 --- a/spp_programs/readme/HISTORY.md +++ b/spp_programs/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.2.2 + +- fix(security): the Program Viewer role no longer carries the Tier-2 `spp_registry.group_registry_viewer` group, which gates the standalone Registry Search portal menu and exposed a broad registrant-PII enumeration surface to a read-only program role. It now uses the Tier-3 `spp_registry.group_registry_read` group instead, preserving the registrant read needed for program cross-references (same read ACLs, defined in `spp_base_common`) without the Registry app menu. Includes a migration that re-points the role and re-syncs already-assigned users on upgrade. + ### 19.0.2.2.1 - fix(security): make the async operation lock a server-side boundary. The diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html index b18978828..bfa512194 100644 --- a/spp_programs/static/description/index.html +++ b/spp_programs/static/description/index.html @@ -658,6 +658,20 @@

      Changelog

    +

    19.0.2.2.2

    +
      +
    • fix(security): the Program Viewer role no longer carries the Tier-2 +spp_registry.group_registry_viewer group, which gates the +standalone Registry Search portal menu and exposed a broad +registrant-PII enumeration surface to a read-only program role. It now +uses the Tier-3 spp_registry.group_registry_read group instead, +preserving the registrant read needed for program cross-references +(same read ACLs, defined in spp_base_common) without the Registry +app menu. Includes a migration that re-points the role and re-syncs +already-assigned users on upgrade.
    • +
    +
    +

    19.0.2.2.1

    • fix(security): make the async operation lock a server-side boundary. @@ -674,7 +688,7 @@

      19.0.2.2.1

      acquire/release from the initiating user keeps working.
    -
    +

    19.0.2.1.3

    • fix(security): align Program Viewer / Validator / Cycle Approver roles @@ -693,7 +707,7 @@

      19.0.2.1.3

      cross-references — only the dedicated top-level menu disappears.
    -
    +

    19.0.2.1.2

    • fix(security): add global ir.rule records on @@ -707,7 +721,7 @@

      19.0.2.1.2

      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 @@ -716,7 +730,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 @@ -727,7 +741,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, @@ -740,7 +754,7 @@

      19.0.2.0.10

      submission on double-click
    -
    +

    19.0.2.0.9

    • Add context flags (skip_registrant_statistics, @@ -753,7 +767,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 @@ -764,7 +778,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 @@ -773,7 +787,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)
    • @@ -782,34 +796,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 @@ -818,7 +832,7 @@

      19.0.2.0.1

      constraint creation
    -
    +

    19.0.2.0.0

    • Initial migration to OpenSPP2
    • diff --git a/spp_programs/tests/__init__.py b/spp_programs/tests/__init__.py index 24988efbe..e5a31c352 100644 --- a/spp_programs/tests/__init__.py +++ b/spp_programs/tests/__init__.py @@ -44,3 +44,4 @@ from . import test_cycle_null_entitlement_approval from . import test_approve_entitlements_program_isolation from . import test_payment_batch_payment_ids +from . import test_program_viewer_registry_scope diff --git a/spp_programs/tests/test_program_viewer_registry_scope.py b/spp_programs/tests/test_program_viewer_registry_scope.py new file mode 100644 index 000000000..708eb9f08 --- /dev/null +++ b/spp_programs/tests/test_program_viewer_registry_scope.py @@ -0,0 +1,67 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""The Program Viewer role must be able to read registrant data for program +cross-references, but must NOT carry the Tier-2 ``group_registry_viewer`` +group, which gates the standalone Registry Search portal menu +(``spp_registry_search.menu_registry_search``) — an over-broad registrant PII +enumeration surface for a read-only program role. + +The role is switched to the Tier-3 ``group_registry_read`` technical group, +which grants the same registrant read ACLs (defined in ``spp_base_common``) +without the Registry app menu. +""" + +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestProgramViewerRegistryScope(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.user = cls.env["res.users"].create( + { + "name": "Program Viewer Test", + "login": "program_viewer_scope_test", + "email": "pv_scope@example.com", + } + ) + cls.env["res.users.role.line"].create( + { + "user_id": cls.user.id, + "role_id": cls.env.ref("spp_programs.global_role_program_viewer").id, + } + ) + cls.user.set_groups_from_roles() + + # A registrant with an ID number and phone (the sensitive PII models). + cls.registrant = cls.env["res.partner"].create( + {"name": "PV Registrant", "is_registrant": True, "is_group": False} + ) + cls.reg_id = cls.env["spp.registry.id"].create( + { + "partner_id": cls.registrant.id, + "id_type_id": cls.env.ref("spp_vocabulary.code_id_type_national_id").id, + "value": "PV-123", + } + ) + cls.phone = cls.env["spp.phone.number"].create({"partner_id": cls.registrant.id, "phone_no": "09170000000"}) + + def test_program_viewer_lacks_tier2_registry_viewer(self): + """The role must not carry group_registry_viewer (gates the Registry + Search portal menu).""" + self.assertFalse( + self.user.has_group("spp_registry.group_registry_viewer"), + "Program Viewer must not have the Tier-2 registry viewer group (it gates the registry search portal menu)", + ) + + def test_program_viewer_keeps_registrant_read(self): + """Registrant read must be preserved via Tier-3 group_registry_read.""" + self.assertTrue(self.user.has_group("spp_registry.group_registry_read")) + # Functional read of the sensitive PII models as the role user. + self.registrant.with_user(self.user).read(["name"]) + self.reg_id.with_user(self.user).read(["value"]) + self.phone.with_user(self.user).read(["phone_no"]) + + def test_program_viewer_keeps_program_data_read(self): + """The role must still read program/cycle data (from group_programs_viewer).""" + self.assertTrue(self.user.has_group("spp_programs.group_programs_viewer")) From adae53ad3872c94e3f6e76b5d6e61bbe5b3c97ab Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 22:40:28 +0800 Subject: [PATCH 06/18] security(cr): writable selected_field bypasses CR conflict checks (#343) Reviewed head: 5950c8cde9fa97fad4e0b2fa4495549038b8edd1 --- spp_change_request_v2/README.rst | 13 ++ spp_change_request_v2/__manifest__.py | 2 +- .../models/conflict_mixin.py | 135 +++++++++---- spp_change_request_v2/readme/HISTORY.md | 4 + .../static/description/index.html | 40 ++-- .../tests/test_conflict_dynamic_approval.py | 191 ++++++++++++++++++ 6 files changed, 331 insertions(+), 54 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 6830899f3..70dd6cff0 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,19 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.5 +~~~~~~~~~~ + +- fix(security): derive conflict and duplicate detection from the change + actually proposed rather than a user-writable label. + ``selected_field_name`` and the detail's ``field_to_modify`` are both + writable by the requester, so either could be re-pointed at an + unchanged field to clear a field-scoped conflict or drop duplicate + similarity to zero. Detection now compares the detail against the + registrant. Types whose apply strategy writes outside the configured + field mappings fall back to the full configured field set instead of + an empty one, so detection cannot silently disable itself. + 19.0.3.1.4 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index ea5dc8537..af9718699 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.3.1.4", + "version": "19.0.3.1.5", "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/conflict_mixin.py b/spp_change_request_v2/models/conflict_mixin.py index 4f450a29c..63515af98 100644 --- a/spp_change_request_v2/models/conflict_mixin.py +++ b/spp_change_request_v2/models/conflict_mixin.py @@ -291,12 +291,72 @@ def _get_group_member_ids(self): return list(set(member_ids)) + def _proposed_changed_fields(self): + """Return the detail (source) fields a dynamic-approval CR actually + proposes to change. + + These are the mapped fields whose detail value differs from the + registrant's current value — exactly the set the ``field_mapping`` apply + strategy will write. This is derived server-side from the actual data, + NOT from a declared label: both ``selected_field_name`` (only + view-readonly) and the detail's ``field_to_modify`` (freely writable and + validated only to be a real field name, not tied to what apply changes) + are attacker-controlled. A user could otherwise clear a field-scoped + conflict/duplicate by labelling a different, unchanged field while still + changing a scoped field. Scoping to the real diff makes those labels + irrelevant to the security decision. + + Returns a set of detail field names for dynamic-approval CR types that + use the ``field_mapping`` apply strategy, or ``None`` otherwise — for + non-dynamic types, and for dynamic types whose strategy writes outside + the mappings, the full configured field set must always be considered + (their details are not registrant-prefilled snapshots, and an empty + derived set would silently disable detection). + """ + self.ensure_one() + cr_type = self.request_type_id + if not cr_type.use_dynamic_approval: + return None + # The derivation reads the configured field mappings, so it is only + # meaningful for the field_mapping apply strategy. A custom-strategy + # type writes fields the mappings do not describe, so deriving from an + # empty apply_mapping_ids would yield an empty set — which disables + # both field-scoped conflict detection and duplicate detection. Fall + # back to the full configured field set (None) instead of failing open. + if cr_type.apply_strategy != "field_mapping" or not cr_type.apply_mapping_ids: + return None + detail = self.get_detail() + registrant = self.registrant_id + if not detail or not registrant: + return set() + changed = set() + for mapping in self.request_type_id.apply_mapping_ids: + source_field = mapping.source_field + target_field = mapping.target_field + if source_field not in detail._fields or target_field not in registrant._fields: + continue + detail_value = self._normalize_field_value(getattr(detail, source_field, None)) + registrant_value = self._normalize_field_value(getattr(registrant, target_field, None)) + if detail_value != registrant_value: + changed.add(source_field) + return changed + + def _effective_conflict_fields(self, conflict_fields): + """Return the subset of ``conflict_fields`` this CR actually proposes to + change (the security-relevant scope). Full set for non-dynamic types.""" + self.ensure_one() + conflict_fields = set(conflict_fields) + proposed = self._proposed_changed_fields() + if proposed is None: + return conflict_fields + return proposed & conflict_fields + def _filter_by_field_conflicts(self, candidates, rule): """Filter candidate CRs by checking if they modify the same fields. - For dynamic-approval CRs (where selected_field_name is set), only the - selected field is treated as a proposed change. Prefilled fields from - the registrant are ignored for conflict purposes. + For dynamic-approval CRs, the proposed changes are the conflict fields + whose value actually differs from the registrant (derived server-side), + not a user-writable label. Prefilled/unchanged fields are ignored. """ self.ensure_one() @@ -308,14 +368,10 @@ def _filter_by_field_conflicts(self, candidates, rule): if not my_detail: return self.env["spp.change.request"] - # Dynamic approval: only the selected field is a proposed change - my_selected = self.selected_field_name - if my_selected: - if my_selected not in conflict_fields: - return self.env["spp.change.request"] - my_effective_fields = [my_selected] - else: - my_effective_fields = conflict_fields + my_effective_fields = self._effective_conflict_fields(conflict_fields) + if not my_effective_fields: + # This CR changes none of the rule's fields — nothing to conflict on. + return self.env["spp.change.request"] matching = self.env["spp.change.request"] @@ -324,18 +380,10 @@ def _filter_by_field_conflicts(self, candidates, rule): if not candidate_detail: continue - # Determine candidate's effective fields - candidate_selected = candidate.selected_field_name - if candidate_selected: - # Both use dynamic approval: conflict only if same field - if my_selected and candidate_selected != my_selected: - continue - candidate_effective = [candidate_selected] - else: - candidate_effective = conflict_fields + candidate_effective = candidate._effective_conflict_fields(conflict_fields) - # Check overlapping effective fields - fields_to_check = set(my_effective_fields) & set(candidate_effective) + # Overlap = fields BOTH CRs actually propose to change. + fields_to_check = my_effective_fields & candidate_effective for field_name in fields_to_check: if field_name not in my_detail._fields: @@ -439,9 +487,9 @@ def _detect_duplicates(self): def _calculate_similarity(self, other_cr, config): """Calculate similarity percentage between this CR and another. - For dynamic-approval CRs (where selected_field_name is set), only the - selected field is compared. Prefilled fields are ignored to prevent - inflated similarity scores. + For dynamic-approval CRs, only the selected field (derived server-side + from the detail's validated ``field_to_modify``) is compared. Prefilled + fields are ignored to prevent inflated similarity scores. Args: other_cr: Another spp.change.request record @@ -458,22 +506,29 @@ def _calculate_similarity(self, other_cr, config): if not my_detail or not other_detail: return 0.0 - # Dynamic approval: compare only the selected field - my_selected = self.selected_field_name - other_selected = other_cr.selected_field_name - if my_selected and other_selected: - # Different fields selected = not duplicates - if my_selected != other_selected: + # Dynamic approval: compare only the fields actually changed (derived + # server-side from the detail-vs-registrant diff, not a writable label). + my_changed = self._proposed_changed_fields() + other_changed = other_cr._proposed_changed_fields() + if my_changed is not None and other_changed is not None: + # Different set of changed fields (or neither changed anything) = + # not duplicates. + if my_changed != other_changed or not my_changed: return 0.0 - # Same field: compare that field's value only - if my_selected in my_detail._fields and my_selected in other_detail._fields: - my_value = self._normalize_field_value(getattr(my_detail, my_selected, None)) - other_value = self._normalize_field_value(getattr(other_detail, my_selected, None)) - if my_value == other_value: - return 100.0 - elif self._are_similar(my_value, other_value): - return 80.0 - return 0.0 + all_match = True + any_similar = False + for field_name in my_changed: + if field_name not in my_detail._fields or field_name not in other_detail._fields: + continue + my_value = self._normalize_field_value(getattr(my_detail, field_name, None)) + other_value = self._normalize_field_value(getattr(other_detail, field_name, None)) + if my_value != other_value: + all_match = False + if self._are_similar(my_value, other_value): + any_similar = True + if all_match: + return 100.0 + return 80.0 if any_similar else 0.0 # Static CRs (or mixed): original logic check_fields = config.get_check_fields_list() diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 12a6f1f5f..f4265e08a 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.5 + +- fix(security): derive conflict and duplicate detection from the change actually proposed rather than a user-writable label. `selected_field_name` and the detail's `field_to_modify` are both writable by the requester, so either could be re-pointed at an unchanged field to clear a field-scoped conflict or drop duplicate similarity to zero. Detection now compares the detail against the registrant. Types whose apply strategy writes outside the configured field mappings fall back to the full configured field set instead of an empty one, so detection cannot silently disable itself. + ### 19.0.3.1.4 - fix(security): scope the CR Requestor, Local Validator and HQ Validator roles to Tier-3 registry read instead of Tier-2 registry viewer. The viewer tier gates the Registry Search portal, a broad registrant-PII enumeration surface these change-request roles do not need; registrant read access is unchanged. A migration re-points the roles and resynchronises existing users, since the role definitions are `noupdate`. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 5b03c0fbf..1e1fe1397 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,20 @@

      Changelog

    +

    19.0.3.1.5

    +
      +
    • fix(security): derive conflict and duplicate detection from the change +actually proposed rather than a user-writable label. +selected_field_name and the detail’s field_to_modify are both +writable by the requester, so either could be re-pointed at an +unchanged field to clear a field-scoped conflict or drop duplicate +similarity to zero. Detection now compares the detail against the +registrant. Types whose apply strategy writes outside the configured +field mappings fall back to the full configured field set instead of +an empty one, so detection cannot silently disable itself.
    • +
    +
    +

    19.0.3.1.4

    • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1350,7 +1364,7 @@

      19.0.3.1.4

      are noupdate.
    -
    +

    19.0.3.1.3

    • fix(security): add ownership and area record rules to every concrete @@ -1367,7 +1381,7 @@

      19.0.3.1.3

      unrestricted delete their access-control entries grant.
    -
    +

    19.0.3.1.2

    • fix(security): route and apply the same single field for @@ -1380,7 +1394,7 @@

      19.0.3.1.2

      the routing selector.
    -
    +

    19.0.3.1.1

    • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1394,7 +1408,7 @@

      19.0.3.1.1

      applied) so the constraint applies cleanly on upgrade.
    -
    +

    19.0.3.1.0

    • revert(change_request): restore the create-a-new-individual Add @@ -1412,7 +1426,7 @@

      19.0.3.1.0

      not restored here; reinstate separately if needed.
    -
    +

    19.0.3.0.0

    • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1434,7 +1448,7 @@

      19.0.3.0.0

      must adapt (see #1133).
    -
    +

    19.0.2.0.8

    • fix(views): disable inline creation of CR document types on the Change @@ -1445,7 +1459,7 @@

      19.0.2.0.8

      Documents” modal (missing Name field) that blocked saving (#1125)
    -
    +

    19.0.2.0.7

    • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1457,7 +1471,7 @@

      19.0.2.0.7

      dependencies.
    -
    +

    19.0.2.0.6

    • fix(views): route post-submit CRs (pending / approved / applied / @@ -1472,7 +1486,7 @@

      19.0.2.0.6

      list so row-click goes through the stage router.
    -
    +

    19.0.2.0.5

    • fix(security): add a global ir.rule on spp.change.request that @@ -1485,27 +1499,27 @@

      19.0.2.0.5

      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_change_request_v2/tests/test_conflict_dynamic_approval.py b/spp_change_request_v2/tests/test_conflict_dynamic_approval.py index dfebdd915..1ed9bb2f5 100644 --- a/spp_change_request_v2/tests/test_conflict_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_conflict_dynamic_approval.py @@ -70,6 +70,19 @@ def _test_field_to_modify_selection(self): "enable_conflict_detection": True, } ) + # Field-mapping definitions: which detail fields map to which registrant + # fields. Conflict/duplicate detection derives the "actually changed" + # fields from these (detail value vs registrant), so a field_mapping + # type needs them — same-named here (given_name -> given_name, etc.). + cls.dynamic_cr_type.write( + { + "apply_mapping_ids": [ + Command.create({"source_field": "given_name", "target_field": "given_name"}), + Command.create({"source_field": "family_name", "target_field": "family_name"}), + Command.create({"source_field": "phone", "target_field": "phone"}), + ], + } + ) # Field-scope conflict rule: checks given_name, family_name cls.field_rule = cls.env["spp.cr.conflict.rule"].create( @@ -481,3 +494,181 @@ def test_static_cr_create_still_runs_conflict_check(self): cr2.conflict_detection_date, "Static CR must run conflict checks at create time (existing behavior).", ) + + # ────────────────────────────────────────────────────────────────────────── + # Security: a user-writable selected_field_name must NOT bypass field-scoped + # conflict/duplicate detection. selected_field_name is only view-readonly; + # CR users have write on their own CRs, so the detection must derive the + # effective changed field from the trusted detail.field_to_modify selection. + # ────────────────────────────────────────────────────────────────────────── + + def test_writing_selected_field_name_cannot_bypass_field_conflict(self): + """Writing selected_field_name to a field outside conflict_fields must + not clear a real field-scoped conflict on a dynamic-approval CR.""" + cr_a = self._create_dynamic_cr() + cr_a.get_detail().write({"field_to_modify": "given_name", "given_name": "NewGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + cr_b.get_detail().write({"field_to_modify": "given_name", "given_name": "NewGivenB"}) + cr_b._run_conflict_checks() + self.assertEqual(cr_b.conflict_status, "warning", "Baseline: same field = conflict.") + + # Attack: re-point selected_field_name to a field NOT in conflict_fields + # (phone). This re-triggers conflict detection via the write override. + cr_b.write({"selected_field_name": "phone"}) + + self.assertEqual( + cr_b.conflict_status, + "warning", + "Writing selected_field_name must not bypass the field-scoped conflict.", + ) + self.assertIn(cr_a, cr_b.conflicting_cr_ids) + + def test_writing_selected_field_name_on_static_cr_cannot_bypass(self): + """On a non-dynamic-approval CR, selected_field_name must be ignored + entirely — writing it cannot narrow the field-scoped conflict.""" + cr_a = self._create_static_cr() + cr_a.get_detail().write({"given_name": "StaticGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_static_cr() + cr_b.get_detail().write({"given_name": "StaticGivenB"}) + cr_b._run_conflict_checks() + self.assertIn(cr_a, cr_b.conflicting_cr_ids, "Baseline: static CRs conflict on given_name.") + + cr_b.write({"selected_field_name": "phone"}) + + self.assertIn( + cr_a, + cr_b.conflicting_cr_ids, + "selected_field_name must not affect conflict detection for non-dynamic CR types.", + ) + + def test_mislabeled_field_to_modify_cannot_bypass_conflict(self): + """Labelling field_to_modify as an unchanged field must not bypass the + conflict on the field actually changed. field_to_modify is user-writable + and does not constrain apply (field_mapping applies every changed field), + so detection must scope to what actually differs from the registrant.""" + cr_a = self._create_dynamic_cr() + cr_a.get_detail().write({"field_to_modify": "given_name", "given_name": "NewGivenA"}) + cr_a._run_conflict_checks() + + # cr_b really changes given_name (a conflict field) but labels the CR as + # modifying phone (unchanged — still the registrant's prefilled value). + cr_b = self._create_dynamic_cr() + cr_b.get_detail().write({"field_to_modify": "phone", "given_name": "NewGivenB"}) + cr_b._run_conflict_checks() + + self.assertEqual( + cr_b.conflict_status, + "warning", + "A real change to a conflict field must be detected regardless of field_to_modify.", + ) + self.assertIn(cr_a, cr_b.conflicting_cr_ids) + + def test_mislabeled_candidate_still_detected_as_conflict(self): + """A prior CR that mislabels field_to_modify while actually changing a + conflict field must still be found as a conflicting candidate.""" + # cr_a really changes given_name but labels field_to_modify = phone. + cr_a = self._create_dynamic_cr() + cr_a.get_detail().write({"field_to_modify": "phone", "given_name": "MislabeledGivenA"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + cr_b.get_detail().write({"field_to_modify": "given_name", "given_name": "NewGivenB"}) + cr_b._run_conflict_checks() + + self.assertIn( + cr_a, + cr_b.conflicting_cr_ids, + "A candidate that actually changed the conflict field must be detected even if mislabeled.", + ) + + def test_writing_selected_field_name_cannot_bypass_duplicate(self): + """Writing selected_field_name must not drop duplicate similarity to 0.""" + dup_config = self.env["spp.cr.duplicate.config"].create( + {"cr_type_id": self.dynamic_cr_type.id, "similarity_threshold": 50.0} + ) + self.dynamic_cr_type.write({"enable_duplicate_detection": True, "duplicate_detection_config_id": dup_config.id}) + try: + cr_a = self._create_dynamic_cr() + cr_a.get_detail().write({"field_to_modify": "given_name", "given_name": "SameValue"}) + cr_a._run_conflict_checks() + + cr_b = self._create_dynamic_cr() + cr_b.get_detail().write({"field_to_modify": "given_name", "given_name": "SameValue"}) + self.assertEqual(cr_b._calculate_similarity(cr_a, dup_config), 100.0, "Baseline duplicate.") + + # Attack: re-point selected_field_name away from the real field. + cr_b.write({"selected_field_name": "phone"}) + + self.assertEqual( + cr_b._calculate_similarity(cr_a, dup_config), + 100.0, + "Writing selected_field_name must not bypass duplicate detection.", + ) + finally: + self.dynamic_cr_type.write({"enable_duplicate_detection": False, "duplicate_detection_config_id": False}) + dup_config.unlink() + + def test_dynamic_custom_strategy_still_detects_conflicts(self): + """A dynamic type whose strategy writes outside the mappings must not + silently lose detection. + + The changed-field derivation reads apply_mapping_ids, which a custom + apply strategy does not populate. Deriving from an empty mapping set + would yield an empty "changed" set, which would make the field-scoped + conflict filter match nothing and drive duplicate similarity to 0 — + disabling both checks for that configuration. The derivation must fall + back to the full configured field set instead. + """ + custom_type = self.CRType.create( + { + "name": "Dynamic Custom Strategy", + "code": "dyn_custom_strategy_test", + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "custom", + # A custom strategy requires an apply model (_check_apply_config). + # The strategy is never executed here — the test only exercises + # conflict detection — so any registered apply model will do. + "apply_model": "spp.cr.apply.add_member", + "approval_definition_id": self.approval_def.id, + "use_dynamic_approval": True, + "candidate_definition_ids": [Command.link(self.approval_def.id)], + "enable_conflict_detection": True, + } + ) + self.env["spp.cr.conflict.rule"].create( + { + "name": "Field Conflict (custom strategy)", + "cr_type_id": custom_type.id, + "scope": "field", + "action": "warn", + "conflict_fields": "given_name, family_name", + } + ) + + def _make(): + cr = self.CR.create({"request_type_id": custom_type.id, "registrant_id": self.registrant.id}) + cr.get_detail().write({"field_to_modify": "given_name", "given_name": "CollidingValue"}) + return cr + + cr_a = _make() + cr_a._run_conflict_checks() + cr_b = _make() + + # No mappings configured, so the derivation must return None (use the + # full configured set) rather than an empty set. + self.assertIsNone( + cr_b._proposed_changed_fields(), + "A dynamic type without field mappings must not derive an empty changed-field set.", + ) + + cr_b._run_conflict_checks() + self.assertNotEqual( + cr_b.conflict_status, + "none", + "Conflict detection must still fire for a dynamic custom-strategy type.", + ) From c739519b3e46dcbe79fef38c5813d99915632686 Mon Sep 17 00:00:00 2001 From: Edwin N Gonzales Date: Fri, 14 Aug 2026 23:30:28 +0800 Subject: [PATCH 07/18] security(spp_change_request_v2): enforce manager authorization on CR apply (server-side) (#365) Reviewed head: 9b2a9682c9c7741f12380e8f8347fcbd53f2b030 --- spp_change_request_v2/README.rst | 14 +++ spp_change_request_v2/__manifest__.py | 2 +- .../models/change_request.py | 80 ++++++++----- spp_change_request_v2/readme/HISTORY.md | 4 + .../static/description/index.html | 43 ++++--- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_apply_authorization.py | 110 ++++++++++++++++++ 7 files changed, 213 insertions(+), 41 deletions(-) create mode 100644 spp_change_request_v2/tests/test_apply_authorization.py diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 70dd6cff0..278bf2d9b 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,20 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.6 +~~~~~~~~~~ + +- fix(security): require change-request manager rights to apply a change + request. ``action_apply()`` runs the apply strategy with elevated + rights and is callable over RPC, but the manager restriction existed + only on the review button — so a change-request user could apply their + own approved request and drive privileged writes such as membership + changes. The public entry point is now gated and the mechanism moved + to an internal method, so approval-driven auto-apply is unaffected. + **Deployments using the API v2 change-request endpoints must grant the + API user the change-request manager role to keep using the apply + endpoint.** + 19.0.3.1.5 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index af9718699..c01124f74 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.3.1.5", + "version": "19.0.3.1.6", "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 ae12424d0..c6bbc2cdf 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -3,7 +3,7 @@ from markupsafe import escape as html_escape from odoo import _, api, fields, models -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import AccessError, UserError, ValidationError _logger = logging.getLogger(__name__) @@ -1066,7 +1066,11 @@ def _on_approve(self): self._create_audit_event("approved", "pending", "approved") self._create_log("approved") if self.request_type_id.auto_apply_on_approve: - self.action_apply() + # Auto-apply is authorized by the approval workflow itself, so it + # goes through the internal mechanism rather than the manager-gated + # public action_apply (the approver may be a validator, not a + # manager). + self._apply_change_request() def _on_reject(self, reason): super()._on_reject(reason) @@ -1411,32 +1415,56 @@ def _capture_preview_snapshot(self): self.preview_json_snapshot = json.dumps(changes, indent=2, default=str) def action_apply(self): - """Apply the change request to the registrant.""" + """Apply the change request(s) to the registrant. + + Public entrypoint (review button / RPC). Applying runs the apply + strategy under sudo (see ``_do_apply``), which can write models CR + roles cannot (e.g. ``spp.group.membership``), so it must be gated + server-side to managers: the XML button ``groups=`` is NOT an + authorization boundary because Odoo object methods are callable over + RPC. Superuser (sudo) callers and the auto-apply-on-approve path (which + invokes ``_apply_change_request`` directly, already authorized by the + approval workflow) are unaffected. + """ + if not (self.env.su or self.env.user.has_group("spp_change_request_v2.group_cr_manager")): + raise AccessError(_("Only Change Request managers can apply change requests.")) for rec in self: - if rec.is_applied: - raise UserError(_("Changes have already been applied.")) - if rec.approval_state != "approved": - raise UserError(_("Change request must be approved first.")) + rec._apply_change_request() - try: - # Capture preview snapshot before applying - rec._capture_preview_snapshot() - - rec._do_apply() - rec.write( - { - "is_applied": True, - "applied_date": fields.Datetime.now(), - "applied_by_id": self.env.user.id, - "apply_error": False, - } - ) - rec._create_audit_event("applied", "approved", "applied") - rec._create_log("applied") - except Exception as e: - _logger.exception("Failed to apply change request %s", rec.name) - rec.write({"apply_error": str(e)}) - raise + def _apply_change_request(self): + """Apply a single approved change request (no authorization gate). + + Internal mechanism shared by ``action_apply`` (manager-gated public + entrypoint) and auto-apply-on-approve (``_on_approve``, already + authorized by the approval workflow). Underscore-prefixed so it is not + callable over RPC — the authorization boundary lives on + ``action_apply``. + """ + self.ensure_one() + if self.is_applied: + raise UserError(_("Changes have already been applied.")) + if self.approval_state != "approved": + raise UserError(_("Change request must be approved first.")) + + try: + # Capture preview snapshot before applying + self._capture_preview_snapshot() + + self._do_apply() + self.write( + { + "is_applied": True, + "applied_date": fields.Datetime.now(), + "applied_by_id": self.env.user.id, + "apply_error": False, + } + ) + self._create_audit_event("applied", "approved", "applied") + self._create_log("applied") + except Exception as e: + _logger.exception("Failed to apply change request %s", self.name) + self.write({"apply_error": str(e)}) + raise def _do_apply(self): """Execute the apply strategy. diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index f4265e08a..94de3ecfc 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.6 + +- fix(security): require change-request manager rights to apply a change request. `action_apply()` runs the apply strategy with elevated rights and is callable over RPC, but the manager restriction existed only on the review button — so a change-request user could apply their own approved request and drive privileged writes such as membership changes. The public entry point is now gated and the mechanism moved to an internal method, so approval-driven auto-apply is unaffected. **Deployments using the API v2 change-request endpoints must grant the API user the change-request manager role to keep using the apply endpoint.** + ### 19.0.3.1.5 - fix(security): derive conflict and duplicate detection from the change actually proposed rather than a user-writable label. `selected_field_name` and the detail's `field_to_modify` are both writable by the requester, so either could be re-pointed at an unchanged field to clear a field-scoped conflict or drop duplicate similarity to zero. Detection now compares the detail against the registrant. Types whose apply strategy writes outside the configured field mappings fall back to the full configured field set instead of an empty one, so detection cannot silently disable itself. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 1e1fe1397..f7699a2cb 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,21 @@

      Changelog

    +

    19.0.3.1.6

    +
      +
    • fix(security): require change-request manager rights to apply a change +request. action_apply() runs the apply strategy with elevated +rights and is callable over RPC, but the manager restriction existed +only on the review button — so a change-request user could apply their +own approved request and drive privileged writes such as membership +changes. The public entry point is now gated and the mechanism moved +to an internal method, so approval-driven auto-apply is unaffected. +Deployments using the API v2 change-request endpoints must grant the +API user the change-request manager role to keep using the apply +endpoint.
    • +
    +
    +

    19.0.3.1.5

    • fix(security): derive conflict and duplicate detection from the change @@ -1352,7 +1367,7 @@

      19.0.3.1.5

      an empty one, so detection cannot silently disable itself.
    -
    +

    19.0.3.1.4

    • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1364,7 +1379,7 @@

      19.0.3.1.4

      are noupdate.
    -
    +

    19.0.3.1.3

    • fix(security): add ownership and area record rules to every concrete @@ -1381,7 +1396,7 @@

      19.0.3.1.3

      unrestricted delete their access-control entries grant.
    -
    +

    19.0.3.1.2

    • fix(security): route and apply the same single field for @@ -1394,7 +1409,7 @@

      19.0.3.1.2

      the routing selector.
    -
    +

    19.0.3.1.1

    • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1408,7 +1423,7 @@

      19.0.3.1.1

      applied) so the constraint applies cleanly on upgrade.
    -
    +

    19.0.3.1.0

    • revert(change_request): restore the create-a-new-individual Add @@ -1426,7 +1441,7 @@

      19.0.3.1.0

      not restored here; reinstate separately if needed.
    -
    +

    19.0.3.0.0

    • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1448,7 +1463,7 @@

      19.0.3.0.0

      must adapt (see #1133).
    -
    +

    19.0.2.0.8

    • fix(views): disable inline creation of CR document types on the Change @@ -1459,7 +1474,7 @@

      19.0.2.0.8

      Documents” modal (missing Name field) that blocked saving (#1125)
    -
    +

    19.0.2.0.7

    • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1471,7 +1486,7 @@

      19.0.2.0.7

      dependencies.
    -
    +

    19.0.2.0.6

    • fix(views): route post-submit CRs (pending / approved / applied / @@ -1486,7 +1501,7 @@

      19.0.2.0.6

      list so row-click goes through the stage router.
    -
    +

    19.0.2.0.5

    • fix(security): add a global ir.rule on spp.change.request that @@ -1499,27 +1514,27 @@

      19.0.2.0.5

      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_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index f85ca3317..90171e712 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -28,3 +28,4 @@ from . import test_reason_document_constraint from . import test_detail_record_rules from . import test_cr_roles_registry_scope +from . import test_apply_authorization diff --git a/spp_change_request_v2/tests/test_apply_authorization.py b/spp_change_request_v2/tests/test_apply_authorization.py new file mode 100644 index 000000000..33f16dcd6 --- /dev/null +++ b/spp_change_request_v2/tests/test_apply_authorization.py @@ -0,0 +1,110 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Server-side authorization for applying change requests. + +``action_apply()`` sudoes the apply strategy (which writes ``spp.group.membership`` +as superuser, bypassing the ACLs that make membership read-only for CR roles). +The manager restriction used to live only on the XML button, but Odoo object +methods are RPC-callable, so a plain ``group_cr_user`` could invoke +``action_apply()`` directly on an approved CR and drive superuser membership +writes. The public entrypoint must enforce ``group_cr_manager`` server-side, +while the internal apply mechanism (used by auto-apply-on-approve, which runs +as the approving validator) stays reachable. +""" + +from odoo import Command, fields +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase + +from .common import get_or_create_cr_type + + +class TestApplyAuthorization(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + P = cls.env["res.partner"] + cls.membership_model = cls.env["spp.group.membership"] + cls.cr_model = cls.env["spp.change.request"] + + cls.group = P.create({"name": "Authz Household", "is_registrant": True, "is_group": True}) + cls.member = P.create({"name": "Authz Member", "is_registrant": True, "is_group": False}) + cls.membership = cls.membership_model.create( + {"group": cls.group.id, "individual": cls.member.id, "start_date": fields.Datetime.now()} + ) + cls.cr_type = get_or_create_cr_type(cls.env, "remove_member") + + base_user = cls.env.ref("base.group_user") + + def _user(login, group_xmlid): + return cls.env["res.users"].create( + { + "name": login, + "login": login, + "email": f"{login}@example.com", + "group_ids": [ + Command.link(base_user.id), + Command.link(cls.env.ref(group_xmlid).id), + ], + } + ) + + cls.cr_user = _user("authz_cr_user", "spp_change_request_v2.group_cr_user") + cls.cr_manager = _user("authz_cr_manager", "spp_change_request_v2.group_cr_manager") + cls.cr_validator = _user("authz_cr_validator", "spp_change_request_v2.group_cr_validator") + + def _make_approved_cr(self, owner=None): + """Create an approved remove_member CR. + + ``owner`` sets create_uid so the CR passes the cr_user ownership record + rule (``rule_cr_user``) — otherwise a non-owning cr_user is blocked by + that rule (a read AccessError) and the apply-authorization gate under + test would never be reached. Approval is stamped via sudo to simulate a + CR already approved through the workflow. + """ + cr_model = self.cr_model.with_user(owner) if owner else self.cr_model + cr = cr_model.create({"request_type_id": self.cr_type.id, "registrant_id": self.group.id}) + cr.get_detail().write( + { + "individual_id": self.member.id, + "membership_id": self.membership.id, + "end_reason": "left_household", + } + ) + cr.sudo().approval_state = "approved" + return cr + + def test_cr_user_cannot_apply_over_rpc(self): + """A non-manager cr_user calling action_apply directly on their OWN + approved CR must be denied by the server-side manager gate, and no + membership write must occur.""" + cr = self._make_approved_cr(owner=self.cr_user) + with self.assertRaises(AccessError): + cr.with_user(self.cr_user).action_apply() + self.assertFalse(cr.is_applied) + self.assertFalse(self.membership.ended_date, "membership must be untouched when apply is denied") + + def test_validator_cannot_apply_directly(self): + """A validator (not a manager) is also blocked from calling action_apply + directly. Validators cause an apply only by approving (auto-apply via + _on_approve), not by invoking the manager-only public entrypoint.""" + cr = self._make_approved_cr() + with self.assertRaises(AccessError): + cr.with_user(self.cr_validator).action_apply() + self.assertFalse(cr.is_applied) + + def test_manager_can_apply(self): + """A cr_manager may apply (regression).""" + cr = self._make_approved_cr() + cr.with_user(self.cr_manager).action_apply() + self.assertTrue(cr.is_applied) + self.assertTrue(self.membership.ended_date) + + def test_auto_apply_on_approve_runs_for_non_manager_approver(self): + """Auto-apply-on-approve must still work when the approver is a + validator (not a manager): _on_approve routes through the internal + apply mechanism, which is not gated.""" + cr = self._make_approved_cr() + cr.request_type_id.auto_apply_on_approve = True + cr.with_user(self.cr_validator)._on_approve() + self.assertTrue(cr.is_applied) + self.assertTrue(self.membership.ended_date) From e6f8edd1474c54ff4d5828ac26f27bbb6566757e Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Tue, 25 Aug 2026 13:34:37 +0700 Subject: [PATCH 08/18] docs: correct spp_programs version in force-unlock cross-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The is_locked/locked_reason write guard shipped as spp_programs 19.0.2.2.2, not 19.0.2.2.1 — 2.2.1 is the unrelated Enroll Eligible pause fix already on 19.0. The stale reference dates from the version bump spp_programs took when 19.0 claimed 2.2.1. --- spp_farmer_registry_demo/README.rst | 2 +- spp_farmer_registry_demo/readme/HISTORY.md | 2 +- spp_farmer_registry_demo/static/description/index.html | 2 +- spp_program_geofence/README.rst | 2 +- spp_program_geofence/readme/HISTORY.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spp_farmer_registry_demo/README.rst b/spp_farmer_registry_demo/README.rst index a3e4c37ec..a2d356e04 100644 --- a/spp_farmer_registry_demo/README.rst +++ b/spp_farmer_registry_demo/README.rst @@ -126,7 +126,7 @@ Changelog - fix(demo): release/force the cycle operation lock through the ``_release_operation_lock`` helper instead of writing ``is_locked`` directly, so demo generation stays compatible with the - ``spp_programs`` 19.0.2.2.1 guard that restricts direct writes to the + ``spp_programs`` 19.0.2.2.2 guard that restricts direct writes to the lock fields to system admins. 19.0.2.1.4 diff --git a/spp_farmer_registry_demo/readme/HISTORY.md b/spp_farmer_registry_demo/readme/HISTORY.md index d184ef699..40ec0d85b 100644 --- a/spp_farmer_registry_demo/readme/HISTORY.md +++ b/spp_farmer_registry_demo/readme/HISTORY.md @@ -2,7 +2,7 @@ - fix(demo): release/force the cycle operation lock through the `_release_operation_lock` helper instead of writing `is_locked` directly, - so demo generation stays compatible with the `spp_programs` 19.0.2.2.1 + so demo generation stays compatible with the `spp_programs` 19.0.2.2.2 guard that restricts direct writes to the lock fields to system admins. ### 19.0.2.1.4 diff --git a/spp_farmer_registry_demo/static/description/index.html b/spp_farmer_registry_demo/static/description/index.html index ddfd2bfed..8155ce059 100644 --- a/spp_farmer_registry_demo/static/description/index.html +++ b/spp_farmer_registry_demo/static/description/index.html @@ -493,7 +493,7 @@

      19.0.2.1.5

    • fix(demo): release/force the cycle operation lock through the _release_operation_lock helper instead of writing is_locked directly, so demo generation stays compatible with the -spp_programs 19.0.2.2.1 guard that restricts direct writes to the +spp_programs 19.0.2.2.2 guard that restricts direct writes to the lock fields to system admins.
    diff --git a/spp_program_geofence/README.rst b/spp_program_geofence/README.rst index 6459f0e36..850b64219 100644 --- a/spp_program_geofence/README.rst +++ b/spp_program_geofence/README.rst @@ -77,7 +77,7 @@ Changelog - fix(security): route the async import lock through the operation-lock helpers so it keeps working under the new ``spp.program`` write guard. - ``spp_programs`` 19.0.2.2.1 restricts direct writes to ``is_locked`` / + ``spp_programs`` 19.0.2.2.2 restricts direct writes to ``is_locked`` / ``locked_reason`` to system administrators; the geofence import acquired and released the lock with plain writes as the initiating (non-admin) user, which the guard would reject — leaving the program diff --git a/spp_program_geofence/readme/HISTORY.md b/spp_program_geofence/readme/HISTORY.md index 92c36283f..a7e3e719d 100644 --- a/spp_program_geofence/readme/HISTORY.md +++ b/spp_program_geofence/readme/HISTORY.md @@ -2,7 +2,7 @@ - fix(security): route the async import lock through the operation-lock helpers so it keeps working under the new `spp.program` write guard. - `spp_programs` 19.0.2.2.1 restricts direct writes to `is_locked` / + `spp_programs` 19.0.2.2.2 restricts direct writes to `is_locked` / `locked_reason` to system administrators; the geofence import acquired and released the lock with plain writes as the initiating (non-admin) user, which the guard would reject — leaving the program stuck locked. It now From 3f1fbd901a8ca0f28d9255a5dd44946b4e2a4369 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Tue, 25 Aug 2026 14:11:13 +0700 Subject: [PATCH 09/18] fix(security): scope Create-Group member wizards to the parent change request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The member wizard and its phone/bank children are transient models whose ACLs grant change-request users read, write, create and delete, and no record rule covered them. Odoo grants transient models no implicit creator-only scoping — ir.rule applies to them as it does to persistent models, and with no rule the domain resolves to true — so any change-request user could enumerate, read, alter or delete another user's proposed-member names, birthdates, phone numbers and bank account numbers. Each wizard model now carries the same parent-change-request ownership rules as the persistent Create-Group detail rows, scoped on every operation its ACL grants, plus the global area filter. The completeness test skipped transient models on the strength of the same false premise; removing that skip is what surfaced the missing area-filter rules. --- spp_change_request_v2/README.rst | 17 ++ spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 4 + .../security/area_filter_rules.xml | 60 +++++ spp_change_request_v2/security/rules.xml | 205 ++++++++++++++++++ .../static/description/index.html | 50 +++-- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_detail_record_rules.py | 9 +- .../tests/test_transient_wizard_isolation.py | 151 +++++++++++++ 9 files changed, 478 insertions(+), 21 deletions(-) create mode 100644 spp_change_request_v2/tests/test_transient_wizard_isolation.py diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 51c5c2c71..d76c8202d 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,23 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.8 +~~~~~~~~~~ + +- fix(security): scope the Create-Group member wizards to the parent + change request. ``spp.cr.detail.create_group.member.wizard`` and its + ``.phone`` / ``.bank`` children are transient models whose + access-control entries grant change-request users read, write, create + **and** delete, and no record rule covered them. Transient models get + no implicit creator-only scoping from the ORM — ``ir.rule`` applies to + them as it does to persistent models, and with no rule the domain + resolves to true — so any change-request user could enumerate, read, + alter or delete another user's proposed-member data, including names, + birthdates, phone numbers and bank account numbers. Each wizard model + now carries the same parent-change-request ownership rules as the + persistent Create-Group detail rows, scoped on every operation its + access-control entry grants. + 19.0.3.1.7 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 1fe3b5c2d..aad2050b8 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.3.1.7", + "version": "19.0.3.1.8", "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/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index e13df2dbc..aae78ec74 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.8 + +- fix(security): scope the Create-Group member wizards to the parent change request. `spp.cr.detail.create_group.member.wizard` and its `.phone` / `.bank` children are transient models whose access-control entries grant change-request users read, write, create **and** delete, and no record rule covered them. Transient models get no implicit creator-only scoping from the ORM — `ir.rule` applies to them as it does to persistent models, and with no rule the domain resolves to true — so any change-request user could enumerate, read, alter or delete another user's proposed-member data, including names, birthdates, phone numbers and bank account numbers. Each wizard model now carries the same parent-change-request ownership rules as the persistent Create-Group detail rows, scoped on every operation its access-control entry grants. + ### 19.0.3.1.7 - fix(security): require change-request manager rights to apply a change request. `action_apply()` runs the apply strategy with elevated rights and is callable over RPC, but the manager restriction existed only on the review button — so a change-request user could apply their own approved request and drive privileged writes such as membership changes. The public entry point is now gated and the mechanism moved to an internal method, so approval-driven auto-apply is unaffected. **Deployments using the API v2 change-request endpoints must grant the API user the change-request manager role to keep using the apply endpoint.** diff --git a/spp_change_request_v2/security/area_filter_rules.xml b/spp_change_request_v2/security/area_filter_rules.xml index f530b6006..001e58068 100644 --- a/spp_change_request_v2/security/area_filter_rules.xml +++ b/spp_change_request_v2/security/area_filter_rules.xml @@ -298,4 +298,64 @@ can be referenced without defensive guards. + + + + CR Detail (create_group.member.wizard): visible only within user's center areas + + [('detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + CR Detail (create_group.member.wizard.phone): visible only within user's center areas + + [('wizard_id.detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + + + CR Detail (create_group.member.wizard.bank): visible only within user's center areas + + [('wizard_id.detail_id.change_request_id.registrant_id.area_id', 'child_of', user.center_area_ids.ids)] if user.center_area_ids else [] + + + + + + diff --git a/spp_change_request_v2/security/rules.xml b/spp_change_request_v2/security/rules.xml index 6d4dc42a7..952f1daf4 100644 --- a/spp_change_request_v2/security/rules.xml +++ b/spp_change_request_v2/security/rules.xml @@ -892,4 +892,209 @@ + + + + CR Detail (create_group.member.wizard): User Access + + [ + '|', + ('detail_id.change_request_id.create_uid', '=', user.id), + ('detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.member.wizard): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.phone): User Access + + [ + '|', + ('wizard_id.detail_id.change_request_id.create_uid', '=', user.id), + ('wizard_id.detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.member.wizard.phone): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.phone): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.phone): Manager Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.bank): User Access + + [ + '|', + ('wizard_id.detail_id.change_request_id.create_uid', '=', user.id), + ('wizard_id.detail_id.change_request_id.registrant_id', 'in', user.partner_id.ids) + ] + + + + + + + + CR Detail (create_group.member.wizard.bank): Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.bank): HQ Validator Access + + [(1, '=', 1)] + + + + + + + + CR Detail (create_group.member.wizard.bank): Manager Access + + [(1, '=', 1)] + + + + + + diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index d41b6c92a..fd93d8398 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,24 @@

    Changelog

    +

    19.0.3.1.8

    +
      +
    • fix(security): scope the Create-Group member wizards to the parent +change request. spp.cr.detail.create_group.member.wizard and its +.phone / .bank children are transient models whose +access-control entries grant change-request users read, write, create +and delete, and no record rule covered them. Transient models get +no implicit creator-only scoping from the ORM — ir.rule applies to +them as it does to persistent models, and with no rule the domain +resolves to true — so any change-request user could enumerate, read, +alter or delete another user’s proposed-member data, including names, +birthdates, phone numbers and bank account numbers. Each wizard model +now carries the same parent-change-request ownership rules as the +persistent Create-Group detail rows, scoped on every operation its +access-control entry grants.
    • +
    +
    +

    19.0.3.1.7

    • fix(security): require change-request manager rights to apply a change @@ -1353,7 +1371,7 @@

      19.0.3.1.7

      endpoint.
    -
    +

    19.0.3.1.6

    • fix(security): derive conflict and duplicate detection from the change @@ -1367,7 +1385,7 @@

      19.0.3.1.6

      an empty one, so detection cannot silently disable itself.
    -
    +

    19.0.3.1.5

    • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1379,7 +1397,7 @@

      19.0.3.1.5

      are noupdate.
    -
    +

    19.0.3.1.4

    • fix(security): add ownership and area record rules to every concrete @@ -1396,7 +1414,7 @@

      19.0.3.1.4

      unrestricted delete their access-control entries grant.
    -
    +

    19.0.3.1.3

    • fix(security): route and apply the same single field for @@ -1409,7 +1427,7 @@

      19.0.3.1.3

      the routing selector.
    -
    +

    19.0.3.1.2

    • fix(change_request_v2): adding an ID now looks for a live one of that @@ -1418,7 +1436,7 @@

      19.0.3.1.2

      (#1136)
    -
    +

    19.0.3.1.1

    • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1432,7 +1450,7 @@

      19.0.3.1.1

      applied) so the constraint applies cleanly on upgrade.
    -
    +

    19.0.3.1.0

    • revert(change_request): restore the create-a-new-individual Add @@ -1450,7 +1468,7 @@

      19.0.3.1.0

      not restored here; reinstate separately if needed.
    -
    +

    19.0.3.0.0

    • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1472,7 +1490,7 @@

      19.0.3.0.0

      must adapt (see #1133).
    -
    +

    19.0.2.0.8

    • fix(views): disable inline creation of CR document types on the Change @@ -1483,7 +1501,7 @@

      19.0.2.0.8

      Documents” modal (missing Name field) that blocked saving (#1125)
    -
    +

    19.0.2.0.7

    • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1495,7 +1513,7 @@

      19.0.2.0.7

      dependencies.
    -
    +

    19.0.2.0.6

    • fix(views): route post-submit CRs (pending / approved / applied / @@ -1510,7 +1528,7 @@

      19.0.2.0.6

      list so row-click goes through the stage router.
    -
    +

    19.0.2.0.5

    • fix(security): add a global ir.rule on spp.change.request that @@ -1523,27 +1541,27 @@

      19.0.2.0.5

      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_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 90171e712..c4de2374e 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -29,3 +29,4 @@ from . import test_detail_record_rules from . import test_cr_roles_registry_scope from . import test_apply_authorization +from . import test_transient_wizard_isolation diff --git a/spp_change_request_v2/tests/test_detail_record_rules.py b/spp_change_request_v2/tests/test_detail_record_rules.py index f1933d720..71a552a87 100644 --- a/spp_change_request_v2/tests/test_detail_record_rules.py +++ b/spp_change_request_v2/tests/test_detail_record_rules.py @@ -93,10 +93,11 @@ def test_every_concrete_detail_model_is_fully_scoped(self): problems = [] checked = 0 for model in models: - # Transient models (wizards) enforce creator-only access in the - # ORM itself — non-superusers may only reach records they created - # — so they need no ir.rule. - if self.env[model.model]._abstract or self.env[model.model]._transient: + # Transient models are NOT exempt: ir.rule applies to them the + # same way it applies to persistent models, and Odoo grants no + # implicit creator-only scoping — a transient model with no rule + # resolves to a TRUE domain. Only abstract models are skipped. + if self.env[model.model]._abstract: continue # Skip models cr_user has no ACL path to (global no-group ACLs # count as a path): in a full-stack DB other apps' detail models diff --git a/spp_change_request_v2/tests/test_transient_wizard_isolation.py b/spp_change_request_v2/tests/test_transient_wizard_isolation.py new file mode 100644 index 000000000..ef9eb5ea8 --- /dev/null +++ b/spp_change_request_v2/tests/test_transient_wizard_isolation.py @@ -0,0 +1,151 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Security: the Create-Group member wizards must be scoped to their owner. + +``spp.cr.detail.create_group.member.wizard`` and its ``.phone`` / ``.bank`` +children are ``TransientModel``s carrying proposed-member PII -- given name, +family name, birthdate, birth place, phone numbers and bank account numbers. +Their ACL grants ``group_cr_user`` full read/write/create/unlink and no +``ir.rule`` covers them, so one change-request user can reach another's rows. + +The ``TransientModel`` docstring claims users "may only access the records they +created", but that behaviour is not implemented anywhere in Odoo 19: +``ir.rule._compute_domain`` has no transient branch, so a transient model with +no rule resolves to ``Domain.TRUE``. Transience only bounds the exposure +window -- the vacuum keeps rows for ``transient_age_limit`` (1 hour by default) +and never removes rows touched in the last five minutes. +""" + +from odoo.exceptions import AccessError +from odoo.tests import tagged + +from .common import CRTestCase, get_or_create_cr_type + +_WIZARD = "spp.cr.detail.create_group.member.wizard" + + +@tagged("post_install", "-at_install") +class TestTransientWizardIsolation(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.internal_group = cls.env.ref("base.group_user") + cls.user_group = cls.env.ref("spp_change_request_v2.group_cr_user") + Users = cls.env["res.users"].with_context(no_reset_password=True) + cls.owner = Users.create( + { + "name": "Wizard Owner", + "login": "cr_wizard_owner", + "email": "cr_wizard_owner@test.com", + "group_ids": [(4, cls.internal_group.id), (4, cls.user_group.id)], + } + ) + cls.other = Users.create( + { + "name": "Wizard Other", + "login": "cr_wizard_other", + "email": "cr_wizard_other@test.com", + "group_ids": [(4, cls.internal_group.id), (4, cls.user_group.id)], + } + ) + cls.create_group_type = get_or_create_cr_type(cls.env, "create_group") + + def setUp(self): + super().setUp() + cr = self.CR.with_user(self.owner).create( + { + "request_type_id": self.create_group_type.id, + "registrant_id": self.test_group.id, + } + ) + detail = cr.with_user(self.owner).get_detail() + self.wizard = ( + self.env[_WIZARD] + .with_user(self.owner) + .create( + { + "detail_id": detail.id, + "mode": "new", + "given_name": "Confidential", + "family_name": "Applicant", + "birthdate": "1990-01-01", + "birth_place": "Undisclosed", + } + ) + ) + self.phone = ( + self.env[f"{_WIZARD}.phone"] + .with_user(self.owner) + .create({"wizard_id": self.wizard.id, "phone_no": "09180000001"}) + ) + self.bank = ( + self.env[f"{_WIZARD}.bank"] + .with_user(self.owner) + .create({"wizard_id": self.wizard.id, "acc_number": "SECRET-ACCT-0001"}) + ) + + # ------------------------------------------------------------------ + # Enumeration + # ------------------------------------------------------------------ + + def test_other_user_cannot_search_foreign_wizard(self): + """A second cr_user must not enumerate another user's wizard rows.""" + found = self.env[_WIZARD].with_user(self.other).search([]) + self.assertNotIn( + self.wizard.id, + found.ids, + "another change-request user enumerated a wizard row they do not own " + "(no ir.rule scopes the transient wizard to its creator)", + ) + + def test_other_user_cannot_search_foreign_wizard_children(self): + """Phone and bank child rows must not be enumerable either.""" + phones = self.env[f"{_WIZARD}.phone"].with_user(self.other).search([]) + self.assertNotIn(self.phone.id, phones.ids, "foreign wizard phone row was enumerable") + banks = self.env[f"{_WIZARD}.bank"].with_user(self.other).search([]) + self.assertNotIn(self.bank.id, banks.ids, "foreign wizard bank row was enumerable") + + # ------------------------------------------------------------------ + # Direct access by id + # ------------------------------------------------------------------ + + def test_other_user_cannot_read_foreign_wizard_pii(self): + with self.assertRaises( + AccessError, + msg="another change-request user read proposed-member PII from a wizard they do not own", + ): + self.wizard.with_user(self.other).read(["given_name", "family_name", "birthdate"]) + + def test_other_user_cannot_read_foreign_bank_account(self): + with self.assertRaises( + AccessError, + msg="another change-request user read a proposed member's bank account number", + ): + self.bank.with_user(self.other).read(["acc_number"]) + + # ------------------------------------------------------------------ + # Tampering -- the ACL grants write and unlink to group_cr_user + # ------------------------------------------------------------------ + + def test_other_user_cannot_write_foreign_wizard(self): + with self.assertRaises( + AccessError, + msg="another change-request user overwrote a wizard row they do not own", + ): + self.wizard.with_user(self.other).write({"given_name": "Tampered"}) + + def test_other_user_cannot_unlink_foreign_wizard(self): + with self.assertRaises( + AccessError, + msg="another change-request user deleted a wizard row they do not own", + ): + self.wizard.with_user(self.other).unlink() + + # ------------------------------------------------------------------ + # The owner keeps working + # ------------------------------------------------------------------ + + def test_owner_retains_full_access(self): + """Scoping must not cage the creator out of their own wizard.""" + self.assertIn(self.wizard.id, self.env[_WIZARD].with_user(self.owner).search([]).ids) + self.wizard.with_user(self.owner).write({"given_name": "Updated"}) + self.assertEqual(self.wizard.with_user(self.owner).read(["given_name"])[0]["given_name"], "Updated") From 44a3cd730c1b4186fac18d86668625d7dd6f3b47 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Tue, 25 Aug 2026 15:30:07 +0700 Subject: [PATCH 10/18] fix(security): score duplicate detection on the shared proposed changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparison required the two derived change sets to be identical. A dynamic-approval type applies only the routed field, so a requester could pad their request with a throwaway edit to another mapped field, make the sets unequal, drop similarity to zero and still have their real change applied unaltered — the evasion cost nothing. Confirmed against a live database: an identical pair scored 100 and was flagged, the same pair plus one decoy scored 0 and was not, and apply wrote only the routed field. Similarity is now scored over the fields both requests propose to change, proportionally, on the 1.0 exact / 0.8 fuzzy scale the static path already uses. Padding falls outside the shared set, so it cannot dilute the score, and a mostly identical request no longer collapses to zero the moment one shared field differs. The change set itself is untouched: still derived from the detail-versus-registrant diff and never from the requester-writable selected_field_name or field_to_modify, which is what keeps a mislabelled request detectable. A test now asserts that independence directly rather than relying on the #343 suite to catch a regression. --- spp_change_request_v2/README.rst | 16 ++ spp_change_request_v2/__manifest__.py | 2 +- .../models/conflict_mixin.py | 42 +++-- spp_change_request_v2/readme/HISTORY.md | 4 + .../static/description/index.html | 55 ++++--- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_duplicate_detection_scope.py | 143 ++++++++++++++++++ 7 files changed, 228 insertions(+), 35 deletions(-) create mode 100644 spp_change_request_v2/tests/test_duplicate_detection_scope.py diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index d76c8202d..e214b8889 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,22 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.9 +~~~~~~~~~~ + +- fix(security): duplicate detection now scores the fields both change + requests actually propose to change, instead of demanding the two + derived change sets be identical. Because a dynamic-approval type + applies only the routed field, a requester could add a throwaway edit + to another mapped field, make the two sets unequal and drop similarity + to zero, while apply discarded that edit — so the evasion cost + nothing. Similarity is now computed over the shared changed fields, + proportionally, on the same scale the static path uses, which also + stops a mostly identical multi-field request collapsing to zero as + soon as one shared field differs. The change set is still derived from + the detail-versus-registrant diff and never from the + requester-writable ``selected_field_name`` / ``field_to_modify``. + 19.0.3.1.8 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index aad2050b8..cb75f19be 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.3.1.8", + "version": "19.0.3.1.9", "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/conflict_mixin.py b/spp_change_request_v2/models/conflict_mixin.py index 63515af98..cd616fc46 100644 --- a/spp_change_request_v2/models/conflict_mixin.py +++ b/spp_change_request_v2/models/conflict_mixin.py @@ -511,24 +511,36 @@ def _calculate_similarity(self, other_cr, config): my_changed = self._proposed_changed_fields() other_changed = other_cr._proposed_changed_fields() if my_changed is not None and other_changed is not None: - # Different set of changed fields (or neither changed anything) = - # not duplicates. - if my_changed != other_changed or not my_changed: + # Score the fields BOTH requests actually propose to change. + # + # Requiring the two change sets to be *equal* made detection + # trivially evadable: apply writes only the routed field for a + # dynamic-approval type, so a requester could add a throwaway edit + # to another mapped field, make the sets unequal, drop similarity to + # zero and still have their real change applied unaltered. Scoring + # the intersection ignores such padding, and scoring it + # proportionally (the same 1.0 exact / 0.8 fuzzy scale the static + # path uses) avoids collapsing a mostly identical request to zero + # the moment one shared field differs. + # + # Deliberately not derived from ``selected_field_name`` or + # ``field_to_modify``: both are requester-writable, which is the + # bypass the diff-derived change set exists to close. + shared = my_changed & other_changed + if not shared: return 0.0 - all_match = True - any_similar = False - for field_name in my_changed: - if field_name not in my_detail._fields or field_name not in other_detail._fields: - continue + comparable = [f for f in shared if f in my_detail._fields and f in other_detail._fields] + if not comparable: + return 0.0 + matching_score = 0.0 + for field_name in comparable: my_value = self._normalize_field_value(getattr(my_detail, field_name, None)) other_value = self._normalize_field_value(getattr(other_detail, field_name, None)) - if my_value != other_value: - all_match = False - if self._are_similar(my_value, other_value): - any_similar = True - if all_match: - return 100.0 - return 80.0 if any_similar else 0.0 + if my_value == other_value: + matching_score += 1.0 + elif self._are_similar(my_value, other_value): + matching_score += 0.8 + return (matching_score / len(comparable)) * 100.0 # Static CRs (or mixed): original logic check_fields = config.get_check_fields_list() diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index aae78ec74..80dc932f4 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.3.1.9 + +- fix(security): duplicate detection now scores the fields both change requests actually propose to change, instead of demanding the two derived change sets be identical. Because a dynamic-approval type applies only the routed field, a requester could add a throwaway edit to another mapped field, make the two sets unequal and drop similarity to zero, while apply discarded that edit — so the evasion cost nothing. Similarity is now computed over the shared changed fields, proportionally, on the same scale the static path uses, which also stops a mostly identical multi-field request collapsing to zero as soon as one shared field differs. The change set is still derived from the detail-versus-registrant diff and never from the requester-writable `selected_field_name` / `field_to_modify`. + ### 19.0.3.1.8 - fix(security): scope the Create-Group member wizards to the parent change request. `spp.cr.detail.create_group.member.wizard` and its `.phone` / `.bank` children are transient models whose access-control entries grant change-request users read, write, create **and** delete, and no record rule covered them. Transient models get no implicit creator-only scoping from the ORM — `ir.rule` applies to them as it does to persistent models, and with no rule the domain resolves to true — so any change-request user could enumerate, read, alter or delete another user's proposed-member data, including names, birthdates, phone numbers and bank account numbers. Each wizard model now carries the same parent-change-request ownership rules as the persistent Create-Group detail rows, scoped on every operation its access-control entry grants. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index fd93d8398..080ff0131 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1160,9 +1160,9 @@

      Methods Reference

      spp.cr.detail.base):

      -+-+ @@ -1339,6 +1339,23 @@

      Changelog

      +

      19.0.3.1.9

      +
        +
      • fix(security): duplicate detection now scores the fields both change +requests actually propose to change, instead of demanding the two +derived change sets be identical. Because a dynamic-approval type +applies only the routed field, a requester could add a throwaway edit +to another mapped field, make the two sets unequal and drop similarity +to zero, while apply discarded that edit — so the evasion cost +nothing. Similarity is now computed over the shared changed fields, +proportionally, on the same scale the static path uses, which also +stops a mostly identical multi-field request collapsing to zero as +soon as one shared field differs. The change set is still derived from +the detail-versus-registrant diff and never from the +requester-writable selected_field_name / field_to_modify.
      • +
      +
      +

      19.0.3.1.8

      • fix(security): scope the Create-Group member wizards to the parent @@ -1356,7 +1373,7 @@

        19.0.3.1.8

        access-control entry grants.
      -
      +

      19.0.3.1.7

      • fix(security): require change-request manager rights to apply a change @@ -1371,7 +1388,7 @@

        19.0.3.1.7

        endpoint.
      -
      +

      19.0.3.1.6

      • fix(security): derive conflict and duplicate detection from the change @@ -1385,7 +1402,7 @@

        19.0.3.1.6

        an empty one, so detection cannot silently disable itself.
      -
      +

      19.0.3.1.5

      • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1397,7 +1414,7 @@

        19.0.3.1.5

        are noupdate.
      -
      +

      19.0.3.1.4

      • fix(security): add ownership and area record rules to every concrete @@ -1414,7 +1431,7 @@

        19.0.3.1.4

        unrestricted delete their access-control entries grant.
      -
      +

      19.0.3.1.3

      • fix(security): route and apply the same single field for @@ -1427,7 +1444,7 @@

        19.0.3.1.3

        the routing selector.
      -
      +

      19.0.3.1.2

      • fix(change_request_v2): adding an ID now looks for a live one of that @@ -1436,7 +1453,7 @@

        19.0.3.1.2

        (#1136)
      -
      +

      19.0.3.1.1

      • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1450,7 +1467,7 @@

        19.0.3.1.1

        applied) so the constraint applies cleanly on upgrade.
      -
      +

      19.0.3.1.0

      • revert(change_request): restore the create-a-new-individual Add @@ -1468,7 +1485,7 @@

        19.0.3.1.0

        not restored here; reinstate separately if needed.
      -
      +

      19.0.3.0.0

      • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1490,7 +1507,7 @@

        19.0.3.0.0

        must adapt (see #1133).
      -
      +

      19.0.2.0.8

      • fix(views): disable inline creation of CR document types on the Change @@ -1501,7 +1518,7 @@

        19.0.2.0.8

        Documents” modal (missing Name field) that blocked saving (#1125)
      -
      +

      19.0.2.0.7

      • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1513,7 +1530,7 @@

        19.0.2.0.7

        dependencies.
      -
      +

      19.0.2.0.6

      • fix(views): route post-submit CRs (pending / approved / applied / @@ -1528,7 +1545,7 @@

        19.0.2.0.6

        list so row-click goes through the stage router.
      -
      +

      19.0.2.0.5

      • fix(security): add a global ir.rule on spp.change.request that @@ -1541,27 +1558,27 @@

        19.0.2.0.5

        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_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index c4de2374e..f8d6ba41c 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -30,3 +30,4 @@ from . import test_cr_roles_registry_scope from . import test_apply_authorization from . import test_transient_wizard_isolation +from . import test_duplicate_detection_scope diff --git a/spp_change_request_v2/tests/test_duplicate_detection_scope.py b/spp_change_request_v2/tests/test_duplicate_detection_scope.py new file mode 100644 index 000000000..ce6237e8d --- /dev/null +++ b/spp_change_request_v2/tests/test_duplicate_detection_scope.py @@ -0,0 +1,143 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Security: duplicate detection must not be defeatable by a padded change set. + +The proposed change set is derived from the detail-vs-registrant diff, never +from the requester-writable ``selected_field_name`` / ``field_to_modify`` -- that +independence is what makes a mislabelled request still detectable, and these +tests guard it. + +Comparison used to require the two derived sets to be *equal*. Since a +dynamic-approval type applies only the routed field, a requester could pad their +request with a throwaway edit to another mapped field, make the sets unequal and +drop similarity to zero, while apply discarded the padding -- so the evasion was +free. Similarity is now scored over the fields both requests propose to change, +proportionally, so padding is ignored and a mostly identical request no longer +collapses to zero when one shared field differs. +""" + +from odoo.tests import tagged + +from .common import CRTestCase + + +@tagged("post_install", "-at_install") +class TestDuplicateDetectionScope(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.registrant = cls.Partner.create( + { + "name": "Dup Scope Registrant", + "given_name": "Orig", + "family_name": "OrigFam", + "is_registrant": True, + "is_group": False, + } + ) + + def _make_type(self, code, dynamic): + cr_type = self.CRType.create( + { + "code": code, + "name": code, + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "use_dynamic_approval": dynamic, + "enable_duplicate_detection": True, + "apply_mapping_ids": [ + (0, 0, {"source_field": "given_name", "target_field": "given_name"}), + (0, 0, {"source_field": "family_name", "target_field": "family_name"}), + ], + } + ) + config = self.env["spp.cr.duplicate.config"].create( + { + "name": f"{code} config", + "cr_type_id": cr_type.id, + "time_window_hours": 24, + "similarity_threshold": 70.0, + } + ) + cr_type.duplicate_detection_config_id = config + return cr_type, config + + def _make_cr(self, cr_type, detail_vals, selected="given_name"): + cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id}) + detail = cr.get_detail() + vals = dict(detail_vals) + if cr_type.use_dynamic_approval: + vals["field_to_modify"] = selected + detail.write(vals) + return cr + + # ------------------------------------------------------------------ + # Padding a change set must not clear the check + # ------------------------------------------------------------------ + + def test_identical_dynamic_requests_are_detected(self): + """Control: without a decoy, the duplicate is caught.""" + cr_type, _ = self._make_type("dup_scope_dyn_control", True) + self._make_cr(cr_type, {"given_name": "NewName"}) + second = self._make_cr(cr_type, {"given_name": "NewName"}) + result = second._detect_duplicates() + self.assertTrue(result["has_duplicates"]) + self.assertEqual(result["max_similarity"], 100.0) + + def test_detection_is_not_derived_from_the_writable_label(self): + """The change set must come from the real diff, not the routing label.""" + cr_type, _ = self._make_type("dup_scope_label", True) + cr = self._make_cr(cr_type, {"given_name": "NewName"}, selected="family_name") + self.assertEqual( + cr._proposed_changed_fields(), + {"given_name"}, + "change set must follow the detail-vs-registrant diff, not field_to_modify", + ) + + def test_decoy_edit_cannot_defeat_duplicate_detection(self): + """An edit to a mapped field apply discards must not clear the check.""" + cr_type, _ = self._make_type("dup_scope_dyn_decoy", True) + self._make_cr(cr_type, {"given_name": "NewName"}) + attacker = self._make_cr(cr_type, {"given_name": "NewName", "family_name": "Decoy"}) + + # The decoy is a real diff, so it legitimately appears in the change + # set -- the set is derived from the data, not from what apply writes. + self.assertEqual(attacker._proposed_changed_fields(), {"given_name", "family_name"}) + + # But apply discards it, so it buys the requester nothing ... + strategy = self.env["spp.cr.strategy.field_mapping"] + applied = sorted(m.source_field for m in strategy._effective_mappings(attacker)) + self.assertEqual(applied, ["given_name"], "apply scope is not the single routed field") + + # ... and it must not stop the duplicate being flagged. + result = attacker._detect_duplicates() + self.assertTrue( + result["has_duplicates"], + "a discarded decoy edit defeated duplicate detection", + ) + + def test_routing_a_different_field_is_not_a_duplicate(self): + """Two requests changing genuinely different fields are not duplicates.""" + cr_type, _ = self._make_type("dup_scope_dyn_distinct", True) + self._make_cr(cr_type, {"given_name": "NewName"}, selected="given_name") + other = self._make_cr(cr_type, {"family_name": "OtherFam"}, selected="family_name") + self.assertFalse(other._detect_duplicates()["has_duplicates"]) + + # ------------------------------------------------------------------ + # Scoring: proportional, not all-or-nothing + # ------------------------------------------------------------------ + + def test_partial_match_scores_proportionally(self): + """One of two changed fields identical scores ~50, not 0.""" + cr_type, config = self._make_type("dup_scope_partial", True) + first = self._make_cr(cr_type, {"given_name": "Same", "family_name": "AAAA"}) + second = self._make_cr(cr_type, {"given_name": "Same", "family_name": "ZZZZ"}) + self.assertEqual(first._proposed_changed_fields(), {"given_name", "family_name"}) + similarity = second._calculate_similarity(first, config) + self.assertAlmostEqual(similarity, 50.0, places=1) + + def test_full_match_still_scores_100(self): + cr_type, config = self._make_type("dup_scope_full", True) + first = self._make_cr(cr_type, {"given_name": "Same", "family_name": "Same2"}) + second = self._make_cr(cr_type, {"given_name": "Same", "family_name": "Same2"}) + self.assertEqual(second._calculate_similarity(first, config), 100.0) From a16c7b6c088d1a5d99fc84d82617e5955d7eea1c Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Tue, 25 Aug 2026 16:16:56 +0700 Subject: [PATCH 11/18] fix: restore generated index.html table widths to the generator's output Local README regeneration renders two RST table columns one percent different from CI, and the routine for discarding that difference only reverted the first hunk of a file. In 44a3cd73 the whole diff was that rendering difference spread over several hunks, so one was reverted and the rest committed, leaving spp_change_request_v2's index.html out of sync with what oca-gen-addon-readme produces. Only the column widths are restored; the changelog anchors regenerated alongside them are correct and kept. --- spp_change_request_v2/static/description/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 080ff0131..f16bbf6b5 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1160,9 +1160,9 @@

        Methods Reference

        spp.cr.detail.base):

      Field
      -+-+ From 08d9ae5e81dde93cb63ae89635ad83dce276341e Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 10:15:32 +0700 Subject: [PATCH 12/18] fix(security): guard the operation lock on create as well as write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The is_locked / locked_reason guard covered write only, so a program officer could create a cycle or program already locked, bypassing it entirely. Clearing the lock afterwards goes through the guarded write, so the creator could not undo it without a system administrator — a self-inflicted lockout, confirmed live on both models. The check moves into _assert_operation_lock_writable and is called from create and write, rather than pasting the condition and its message a third and fourth time. sudo() and system administrators are unaffected, so the async pipeline that creates through sudo keeps working. --- spp_programs/models/cycle.py | 51 ++++++++---- spp_programs/models/programs.py | 50 ++++++++---- spp_programs/tests/test_force_unlock_authz.py | 80 +++++++++++++++++++ 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/spp_programs/models/cycle.py b/spp_programs/models/cycle.py index 61f093590..b15bde2f5 100644 --- a/spp_programs/models/cycle.py +++ b/spp_programs/models/cycle.py @@ -1083,23 +1083,42 @@ def _get_related_job_domain(self): related_jobs = jobs.filtered(lambda r: self in r.args[0]) return [("id", "in", related_jobs.ids)] - def write(self, vals): - # ``is_locked`` / ``locked_reason`` form an operation lock protecting - # in-flight async pipelines (entitlement, payment, eligibility). - # Clearing or setting it out of band lets conflicting operations run, - # so direct writes to these fields are restricted to system - # administrators. The pipeline manages the lock through - # ``_acquire_operation_lock`` / ``_release_operation_lock`` (which - # ``sudo()``), and Force Unlock is the admin-only manual override. - if not self.env.su and ("is_locked" in vals or "locked_reason" in vals): - if not self.env.user.has_group("base.group_system"): - raise AccessError( - _( - "Changing the operation lock is restricted to system " - "administrators. The lock is managed automatically by " - "the async pipeline; use Force Unlock only in an emergency." - ) + def _assert_operation_lock_writable(self, vals): + """Reject out-of-band changes to the operation lock fields. + + ``is_locked`` / ``locked_reason`` form an operation lock protecting + in-flight async pipelines (entitlement, payment, eligibility). Clearing + or setting it out of band lets conflicting operations run, so direct + changes to these fields are restricted to system administrators. The + pipeline manages the lock through ``_acquire_operation_lock`` / + ``_release_operation_lock`` (which ``sudo()``), and Force Unlock is the + admin-only manual override. + + Enforced on create as well as write: a record created already locked + would otherwise skip the guard entirely, and its creator could not + clear the lock afterwards without a system administrator. + """ + if self.env.su: + return + if "is_locked" not in vals and "locked_reason" not in vals: + return + if not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Changing the operation lock is restricted to system " + "administrators. The lock is managed automatically by " + "the async pipeline; use Force Unlock only in an emergency." ) + ) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._assert_operation_lock_writable(vals) + return super().create(vals_list) + + def write(self, vals): + self._assert_operation_lock_writable(vals) return super().write(vals) # NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that diff --git a/spp_programs/models/programs.py b/spp_programs/models/programs.py index fc81c92bc..a81ebbc3e 100644 --- a/spp_programs/models/programs.py +++ b/spp_programs/models/programs.py @@ -275,6 +275,11 @@ def _compute_can_edit_configuration(self): @api.model def create(self, vals): + # ``vals`` is a single dict here (this override predates + # ``model_create_multi``), but tolerate a list so the guard cannot be + # sidestepped if the signature is ever widened. + for one in vals if isinstance(vals, list) else [vals]: + self._assert_operation_lock_writable(one) res = super().create(vals) if self.env.context.get("skip_default_managers"): return res @@ -792,23 +797,36 @@ def _get_related_job_domain(self): related_jobs = jobs.filtered(lambda r: self in r.records.program_id) return [("id", "in", related_jobs.ids)] - def write(self, vals): - # ``is_locked`` / ``locked_reason`` form an operation lock protecting - # in-flight async pipelines (enrollment, eligibility). Clearing or - # setting it out of band lets conflicting operations run, so direct - # writes to these fields are restricted to system administrators. The - # pipeline manages the lock through ``_acquire_operation_lock`` / - # ``_release_operation_lock`` (which ``sudo()``), and Force Unlock is - # the admin-only manual override. - if not self.env.su and ("is_locked" in vals or "locked_reason" in vals): - if not self.env.user.has_group("base.group_system"): - raise AccessError( - _( - "Changing the operation lock is restricted to system " - "administrators. The lock is managed automatically by " - "the async pipeline; use Force Unlock only in an emergency." - ) + def _assert_operation_lock_writable(self, vals): + """Reject out-of-band changes to the operation lock fields. + + ``is_locked`` / ``locked_reason`` form an operation lock protecting + in-flight async pipelines (enrollment, eligibility). Clearing or + setting it out of band lets conflicting operations run, so direct + changes to these fields are restricted to system administrators. The + pipeline manages the lock through ``_acquire_operation_lock`` / + ``_release_operation_lock`` (which ``sudo()``), and Force Unlock is the + admin-only manual override. + + Enforced on create as well as write: a record created already locked + would otherwise skip the guard entirely, and its creator could not + clear the lock afterwards without a system administrator. + """ + if self.env.su: + return + if "is_locked" not in vals and "locked_reason" not in vals: + return + if not self.env.user.has_group("base.group_system"): + raise AccessError( + _( + "Changing the operation lock is restricted to system " + "administrators. The lock is managed automatically by " + "the async pipeline; use Force Unlock only in an emergency." ) + ) + + def write(self, vals): + self._assert_operation_lock_writable(vals) return super().write(vals) # NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that diff --git a/spp_programs/tests/test_force_unlock_authz.py b/spp_programs/tests/test_force_unlock_authz.py index 2dda86eb7..1c7fa058f 100644 --- a/spp_programs/tests/test_force_unlock_authz.py +++ b/spp_programs/tests/test_force_unlock_authz.py @@ -179,3 +179,83 @@ def test_eligibility_mark_import_done_releases_lock_as_non_admin(self): manager.with_user(self.officer).mark_import_as_done() self.assertFalse(self.program.is_locked) self.assertFalse(self.program.locked_reason) + + # --- create must be guarded too ------------------------------------ + + def test_cycle_cannot_be_created_already_locked_by_officer(self): + """The guard covered write only, so a locked record could be created. + + The creator then could not clear the lock again, since clearing it goes + through the write guard -- a self-inflicted lockout needing an admin. + """ + today = fields.Date.today() + with self.assertRaises(AccessError): + self.env["spp.cycle"].with_user(self.officer).create( + { + "name": f"Locked At Create {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 2, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "is_locked": True, + "locked_reason": "set at create", + } + ) + + def test_cycle_cannot_be_created_with_locked_reason_by_officer(self): + today = fields.Date.today() + with self.assertRaises(AccessError): + self.env["spp.cycle"].with_user(self.officer).create( + { + "name": f"Reason At Create {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 3, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "locked_reason": "set at create", + } + ) + + def test_program_cannot_be_created_already_locked_by_officer(self): + with self.assertRaises(AccessError): + self.env["spp.program"].with_user(self.officer).create( + {"name": f"Locked Program {uuid.uuid4().hex[:8]}", "is_locked": True} + ) + + def test_cycle_creation_without_lock_fields_still_works(self): + """The guard must not block ordinary creation.""" + cycle = _new_cycle(self.env(user=self.officer), self.program) + self.assertTrue(cycle.id) + self.assertFalse(cycle.is_locked) + + def test_cycle_can_be_created_locked_by_system_admin(self): + today = fields.Date.today() + cycle = self.env["spp.cycle"].with_user(self.system).create( + { + "name": f"Admin Locked {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 4, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "is_locked": True, + "locked_reason": "admin set at create", + } + ) + self.assertTrue(cycle.is_locked) + + def test_cycle_can_be_created_locked_via_sudo(self): + """The async pipeline creates through sudo() and must stay unaffected.""" + today = fields.Date.today() + cycle = self.env["spp.cycle"].sudo().create( + { + "name": f"Sudo Locked {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 5, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "is_locked": True, + "locked_reason": "pipeline", + } + ) + self.assertTrue(cycle.is_locked) + From 6c3e9e21ea003002a1f679a0d23fe9fec919cd1b Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 10:15:32 +0700 Subject: [PATCH 13/18] fix: reject applying a change request that can write nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit field_mapping.apply() returned True even when it had no mapping to write, so the request was stamped applied — with applied_date, an audit event and a log line — having changed nothing. Operators saw a green, applied request whose change had been silently dropped. This happens when the field a dynamic-approval request was routed on loses its mapping, or when the type has none configured, because _effective_mappings fails closed. Applying now raises in that case, so _apply_change_request records apply_error and leaves the request unapplied. A genuine no-op is unaffected and still succeeds: the mappings exist, the registrant simply already holds the proposed values. The fail-closed test from #343 keeps its guarantee — nothing is written for an unmapped selection — and now also expects the rejection. --- .../strategies/field_mapping.py | 37 +++++- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_apply_effective_mappings.py | 116 ++++++++++++++++++ .../tests/test_dynamic_approval.py | 12 +- 4 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 spp_change_request_v2/tests/test_apply_effective_mappings.py diff --git a/spp_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py index 4c09972de..648f790d3 100644 --- a/spp_change_request_v2/strategies/field_mapping.py +++ b/spp_change_request_v2/strategies/field_mapping.py @@ -42,8 +42,37 @@ def apply(self, change_request): if not detail: raise UserError(_("No detail record found.")) + # Fail loudly rather than apply nothing. ``_effective_mappings`` fails + # closed, so an empty result means the change cannot be carried out at + # all -- the routed field lost its mapping, or the type has none + # configured. Returning success here would stamp the request applied, + # with an audit event and a log line, having written nothing: operators + # would see a green, applied request whose change was silently dropped. + # An empty ``values`` below is different and stays allowed: the mappings + # exist, the registrant simply already holds the proposed values. + mappings = self._effective_mappings(change_request) + if not mappings: + selected = change_request.selected_field_name + if selected: + raise UserError( + _( + "The field this change request was routed on (%(field)s) no longer has a " + "mapping on its request type, so applying it would change nothing while " + "recording it as applied. Correct the request type's field mappings, or " + "reset the request to draft to re-route it.", + field=selected, + ) + ) + raise UserError( + _( + "This change request type has no field mapping to apply, so applying it " + "would change nothing while recording it as applied. Configure the request " + "type's field mappings before applying." + ) + ) + values = {} - for mapping in self._effective_mappings(change_request): + for mapping in mappings: source_value = getattr(detail, mapping.source_field, None) current_value = getattr(registrant, mapping.target_field, None) @@ -86,6 +115,12 @@ def apply(self, change_request): name_related_fields = {"family_name", "given_name", "addl_name"} if name_related_fields & set(values.keys()): registrant.name_change() + else: + _logger.info( + "Field mapping for CR %s wrote nothing: the registrant already holds the " + "proposed values.", + change_request.name, + ) return True diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index f8d6ba41c..94c67bfcc 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -31,3 +31,4 @@ from . import test_apply_authorization from . import test_transient_wizard_isolation from . import test_duplicate_detection_scope +from . import test_apply_effective_mappings diff --git a/spp_change_request_v2/tests/test_apply_effective_mappings.py b/spp_change_request_v2/tests/test_apply_effective_mappings.py new file mode 100644 index 000000000..2aa5de8f1 --- /dev/null +++ b/spp_change_request_v2/tests/test_apply_effective_mappings.py @@ -0,0 +1,116 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Applying a change request that can write nothing must fail, not report success. + +``_effective_mappings`` fails closed: for a dynamic-approval type it narrows to +the single routed field, and yields nothing if that field lost its mapping or +none was ever configured. The apply strategy returned ``True`` regardless, so +the request was stamped applied -- with ``applied_date``, an audit event and a +log line -- having written nothing. Operators saw a green, applied request whose +change had been silently dropped. + +A genuine no-op is different and stays allowed: when the mappings exist and the +registrant already holds the proposed values there is nothing to write, and the +request is correctly recorded as applied. +""" + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import CRTestCase + + +@tagged("post_install", "-at_install") +class TestApplyEffectiveMappings(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.registrant = cls.Partner.create( + { + "name": "Apply Scope Registrant", + "given_name": "Orig", + "family_name": "OrigFam", + "is_registrant": True, + "is_group": False, + } + ) + + def _make_type(self, code, dynamic=True, with_mapping=True): + vals = { + "code": code, + "name": code, + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "use_dynamic_approval": dynamic, + } + if with_mapping: + vals["apply_mapping_ids"] = [ + (0, 0, {"source_field": "given_name", "target_field": "given_name"}), + ] + return self.CRType.create(vals) + + def _approved_cr(self, cr_type, detail_vals, selected="given_name"): + cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id}) + detail = cr.get_detail() + vals = dict(detail_vals) + if cr_type.use_dynamic_approval: + vals["field_to_modify"] = selected + detail.write(vals) + cr.sudo().write({"approval_state": "approved"}) + return cr + + # ------------------------------------------------------------------ + # Must fail: nothing can be written + # ------------------------------------------------------------------ + + def test_routed_field_lost_its_mapping(self): + cr_type = self._make_type("apply_scope_lost_mapping") + cr = self._approved_cr(cr_type, {"given_name": "Changed"}) + cr_type.apply_mapping_ids.unlink() + + with self.assertRaisesRegex(UserError, "no longer has a mapping"): + cr.sudo()._apply_change_request() + + self.assertFalse(cr.is_applied, "a request that wrote nothing must not be stamped applied") + self.assertFalse(cr.applied_date) + cr.invalidate_recordset(["apply_error"]) + self.assertTrue(cr.apply_error, "the failure must be recorded on the request") + self.assertEqual(self.registrant.given_name, "Orig") + + def test_dynamic_type_with_no_selected_field(self): + cr_type = self._make_type("apply_scope_unrouted") + cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id}) + cr.get_detail().write({"given_name": "Changed"}) + cr.sudo().write({"approval_state": "approved"}) + self.assertFalse(cr.selected_field_name, "probe assumes the request was never routed") + + with self.assertRaisesRegex(UserError, "no field mapping to apply"): + cr.sudo()._apply_change_request() + self.assertFalse(cr.is_applied) + + def test_type_with_no_mappings_configured(self): + cr_type = self._make_type("apply_scope_no_mappings", dynamic=False, with_mapping=False) + cr = self._approved_cr(cr_type, {"given_name": "Changed"}) + + with self.assertRaisesRegex(UserError, "no field mapping to apply"): + cr.sudo()._apply_change_request() + self.assertFalse(cr.is_applied) + + # ------------------------------------------------------------------ + # Must still succeed + # ------------------------------------------------------------------ + + def test_normal_apply_still_works(self): + cr_type = self._make_type("apply_scope_ok") + cr = self._approved_cr(cr_type, {"given_name": "Changed"}) + cr.sudo()._apply_change_request() + self.assertTrue(cr.is_applied) + self.assertEqual(self.registrant.given_name, "Changed") + + def test_genuine_no_op_is_not_an_error(self): + """Mappings exist, the registrant already holds the value: nothing to write.""" + cr_type = self._make_type("apply_scope_noop") + cr = self._approved_cr(cr_type, {"given_name": self.registrant.given_name}) + cr.sudo()._apply_change_request() + self.assertTrue(cr.is_applied, "a real no-op is legitimately applied") + self.assertFalse(cr.apply_error) diff --git a/spp_change_request_v2/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py index ec48c4ec5..166fc8bc1 100644 --- a/spp_change_request_v2/tests/test_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -1096,14 +1096,22 @@ def test_dynamic_preview_shows_only_selected_field(self): def test_dynamic_apply_unmapped_selected_field_writes_nothing(self): """Fail-closed: a dynamic CR whose selected field has no mapping writes nothing, - even if another mapped detail field was changed.""" + even if another mapped detail field was changed. + + Applying is additionally rejected outright rather than reporting success. + Returning success wrote nothing but still stamped the request applied, + hiding a dropped change from operators; see + ``test_apply_effective_mappings``. The fail-closed guarantee this test + exists for is unchanged -- nothing is written either way. + """ cr = self._create_cr() detail = cr.get_detail() detail.write({"phone": "999-000"}) # Force a selected field that is not present in apply_mapping_ids. cr.selected_field_name = "email" - self._field_mapping_strategy().apply(cr) + with self.assertRaises(UserError): + self._field_mapping_strategy().apply(cr) self.assertEqual(self.registrant.phone, "111-222", "nothing may be applied for an unmapped selection") From af9efd4bf4c467ef1d62884a0c9e96b70226874d Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 13:28:09 +0700 Subject: [PATCH 14/18] fix: treat an empty string as unset in the post-submit freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Odoo stores an unset Char as False, but a JSON-RPC client or integration re-saving a record sends "". The freeze guards normalised only None, so "" did not match the stored False and an idempotent re-save was rejected with the lockout error as though it had altered the approved content. The normalisation also existed twice, verbatim, on spp.change.request and on spp.cr.detail.base — so the gap had to be closed in both places or the two guards would disagree about what counts as a change. It now lives once in models/frozen_value.py, deliberately model-free: change_request is imported before change_request_detail_base, so having either import the other would tie the freeze to model registration order. Clearing a populated frozen field with "" is still rejected; "" reads as unset, not as a licence to blank an approved value. --- .../models/change_request.py | 17 +--- .../models/change_request_detail_base.py | 17 +--- spp_change_request_v2/models/frozen_value.py | 33 ++++++++ spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_frozen_value_normalisation.py | 84 +++++++++++++++++++ 5 files changed, 124 insertions(+), 28 deletions(-) create mode 100644 spp_change_request_v2/models/frozen_value.py create mode 100644 spp_change_request_v2/tests/test_frozen_value_normalisation.py diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index c6bbc2cdf..41b52be86 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -5,6 +5,8 @@ from odoo import _, api, fields, models from odoo.exceptions import AccessError, UserError, ValidationError +from .frozen_value import normalize_frozen_value + _logger = logging.getLogger(__name__) @@ -679,23 +681,10 @@ def create(self, vals_list): "detail_res_model", ) - @staticmethod - def _normalize_frozen_value(value): - """Normalize a value for change detection: recordset -> id, None -> False. - - Odoo stores unset fields as ``False``, but a write payload (JSON-RPC / - integrations) may pass ``None`` for the same field, or a Many2one as a - recordset. Normalizing both sides prevents an idempotent re-save from - being mistaken for a real change and wrongly locked out. - """ - if hasattr(value, "id"): - value = value.id - return value if value is not None else False - def write(self, vals): guarded = [f for f in self._FROZEN_ON_SUBMIT_FIELDS if f in vals] if guarded: - norm = self._normalize_frozen_value + norm = normalize_frozen_value for rec in self: if rec.approval_state in ("draft", "revision") or not rec.approval_state: continue diff --git a/spp_change_request_v2/models/change_request_detail_base.py b/spp_change_request_v2/models/change_request_detail_base.py index 276727aa5..6546f162d 100644 --- a/spp_change_request_v2/models/change_request_detail_base.py +++ b/spp_change_request_v2/models/change_request_detail_base.py @@ -1,6 +1,8 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError +from .frozen_value import normalize_frozen_value + class SPPCRDetailBase(models.AbstractModel): """Abstract base for all CR detail models. @@ -97,19 +99,6 @@ def _protected_content_fields(self, change_request): protected |= {m.source_field for m in cr_type.apply_mapping_ids if m.source_field} return protected - @staticmethod - def _normalize_frozen_value(value): - """Normalize a value for change detection: recordset -> id, None -> False. - - Odoo stores unset fields as ``False``, but a write payload may pass - ``None`` for the same field or a Many2one as a recordset; normalizing - both sides prevents an idempotent re-save from being mistaken for a real - change and wrongly locked out. - """ - if hasattr(value, "id"): - value = value.id - return value if value is not None else False - def _assert_content_editable(self, vals): """Reject edits to proposed-change fields once the CR is submitted. @@ -128,7 +117,7 @@ def _assert_content_editable(self, vals): # Normalize both sides (recordset -> id, None -> False) so an # idempotent re-save, a Many2one written as a recordset, or a # JSON-RPC None is not mistaken for a real change and locked out. - if self._normalize_frozen_value(vals[field_name]) != self._normalize_frozen_value(rec[field_name]): + if normalize_frozen_value(vals[field_name]) != normalize_frozen_value(rec[field_name]): raise UserError( _( "This change request has already been submitted for approval, " diff --git a/spp_change_request_v2/models/frozen_value.py b/spp_change_request_v2/models/frozen_value.py new file mode 100644 index 000000000..00bb76c1a --- /dev/null +++ b/spp_change_request_v2/models/frozen_value.py @@ -0,0 +1,33 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Value normalisation shared by the post-submit freeze guards. + +Once a change request leaves draft, two guards compare an incoming write +payload against the stored value: one on ``spp.change.request`` for the fields +that bind it to what was routed and approved, and one on every +``spp.cr.detail.*`` model for its proposed-change fields. Both must normalise +identically -- if they disagree about what counts as a change, the same payload +is accepted by one and rejected by the other. Defining it once keeps them in +step, and means a gap has to be closed only once. + +Deliberately model-free so importing it registers nothing: ``change_request`` is +imported before ``change_request_detail_base``, so having either import the +other would tie the freeze to model registration order. +""" + + +def normalize_frozen_value(value): + """Normalize a value for comparison against a stored field value. + + - A recordset becomes its id, so a Many2one written as a recordset compares + equal to the stored id rather than looking like a change. + - ``None`` and ``""`` both become ``False``, which is what Odoo actually + stores for an unset field. A JSON-RPC client or integration re-saving a + record sends ``""`` for an empty Char; without collapsing it, that payload + would not match the stored ``False`` and an idempotent re-save would be + rejected as though it altered the approved content. + """ + if hasattr(value, "id"): + value = value.id + if value is None or value == "": + return False + return value diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 94c67bfcc..444c5b017 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -32,3 +32,4 @@ from . import test_transient_wizard_isolation from . import test_duplicate_detection_scope from . import test_apply_effective_mappings +from . import test_frozen_value_normalisation diff --git a/spp_change_request_v2/tests/test_frozen_value_normalisation.py b/spp_change_request_v2/tests/test_frozen_value_normalisation.py new file mode 100644 index 000000000..a30205d37 --- /dev/null +++ b/spp_change_request_v2/tests/test_frozen_value_normalisation.py @@ -0,0 +1,84 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""The post-submit freeze must not reject a payload that changes nothing. + +Both freeze guards -- the one on ``spp.change.request`` for its routing fields +and the one on every ``spp.cr.detail.*`` model for its proposed-change fields -- +compare an incoming write payload against the stored value. Odoo stores an unset +Char as ``False``, but a JSON-RPC client or integration re-saving a record sends +``""``. Normalising only ``None`` left ``""`` looking like a real change, so an +idempotent re-save was rejected with the lockout error as though it had altered +the approved content. + +Both guards share one normalisation helper, so this cannot be fixed on one side +and missed on the other. +""" + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from ..models.frozen_value import normalize_frozen_value +from .common import CRTestCase, get_or_create_cr_type + + +@tagged("post_install", "-at_install") +class TestFrozenValueNormalisation(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.edit_type = get_or_create_cr_type(cls.env, "edit_individual") + + # ------------------------------------------------------------------ + # The helper itself + # ------------------------------------------------------------------ + + def test_unset_representations_all_normalise_together(self): + for value in (None, False, ""): + self.assertIs( + normalize_frozen_value(value), + False, + f"{value!r} must normalise to the stored representation of unset", + ) + + def test_recordset_normalises_to_its_id(self): + self.assertEqual(normalize_frozen_value(self.test_individual), self.test_individual.id) + + def test_real_values_are_preserved(self): + self.assertEqual(normalize_frozen_value("Jane"), "Jane") + self.assertEqual(normalize_frozen_value(7), 7) + self.assertIs(normalize_frozen_value(True), True) + + def test_zero_is_not_treated_as_unset(self): + """``0`` is a real value; only None/False/'' mean unset.""" + self.assertEqual(normalize_frozen_value(0), 0) + + # ------------------------------------------------------------------ + # The CR-level guard + # ------------------------------------------------------------------ + + def test_empty_string_for_an_unset_frozen_field_is_accepted(self): + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.sudo().write({"approval_state": "pending"}) + self.assertFalse(cr.selected_field_old_value, "test assumes the field is unset") + # An integration re-saving the record sends "" for the empty Char. + cr.write({"selected_field_old_value": ""}) + self.assertFalse(cr.selected_field_old_value) + + def test_a_real_change_to_a_frozen_field_is_still_rejected(self): + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.sudo().write({"approval_state": "pending"}) + with self.assertRaises(UserError): + cr.write({"selected_field_old_value": "something else"}) + + def test_clearing_a_populated_frozen_field_is_still_rejected(self): + """'' must read as unset, not as a licence to clear a set value.""" + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.write({"selected_field_old_value": "Original"}) + cr.sudo().write({"approval_state": "pending"}) + with self.assertRaises(UserError): + cr.write({"selected_field_old_value": ""}) From 02e8f3ba7e0f737521c5195bb1447e41db7c420b Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 13:30:32 +0700 Subject: [PATCH 15/18] fix: let a submitted change request bind a missing detail row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detail_res_id is frozen once a request leaves draft so a substituted detail cannot be attached after approval, but the guard compared old against new without telling binding apart from re-pointing. The legitimate False -> id transition was refused too, and the guard has no sudo exemption, so a submitted request that never got a detail row could not be opened or repaired from any context — _ensure_detail() performs exactly that transition. Exempting False -> id outright would let an arbitrary detail be attached to an approved request that happens to have none, which is what the freeze exists to prevent. Binding is therefore accepted only for a row that already points back at this request; _ensure_detail() creates the detail with that link before setting the pointer, so the repair path qualifies and a foreign detail id does not. --- .../models/change_request.py | 38 +++++++- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_frozen_detail_binding.py | 95 +++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 spp_change_request_v2/tests/test_frozen_detail_binding.py diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 41b52be86..ac4c4d0f9 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -681,14 +681,48 @@ def create(self, vals_list): "detail_res_model", ) + def _detail_row_belongs_to_self(self, detail_id): + """Whether ``detail_id`` is a detail row already pointing at this request. + + Used to tell binding a detail apart from substituting one. Reads with + ``sudo()`` because the caller may not have access to the detail model, + and the answer is only ever used to reject or allow, never returned. + """ + self.ensure_one() + if not detail_id or not self.detail_res_model: + return False + model = self.env.get(self.detail_res_model) + if model is None: + return False + parent_field = "x_change_request_id" if "x_change_request_id" in model._fields else "change_request_id" + if parent_field not in model._fields: + return False + detail = model.sudo().browse(int(detail_id)).exists() + return bool(detail) and detail[parent_field].id == self.id + + def _alters_frozen_field(self, field, value): + """Whether writing ``value`` to ``field`` changes what was approved.""" + self.ensure_one() + if normalize_frozen_value(value) == normalize_frozen_value(self[field]): + return False + # Binding a detail row for the first time is not a re-route. A submitted + # request that never got one cannot be opened at all -- get_detail() + # resolves nothing -- and ``_ensure_detail()`` exists to repair exactly + # that, so refusing the write left the record permanently unopenable + # from any context, sudo included. Only a row that already points back + # at this request is accepted, so this cannot be used to attach a + # substituted detail after approval. + if field == "detail_res_id" and not normalize_frozen_value(self[field]): + return not self._detail_row_belongs_to_self(value) + return True + def write(self, vals): guarded = [f for f in self._FROZEN_ON_SUBMIT_FIELDS if f in vals] if guarded: - norm = normalize_frozen_value for rec in self: if rec.approval_state in ("draft", "revision") or not rec.approval_state: continue - if any(norm(vals[f]) != norm(rec[f]) for f in guarded): + if any(rec._alters_frozen_field(f, vals[f]) for f in guarded): raise UserError( _( "A submitted change request is locked to the change it was " diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 444c5b017..ffca1a77e 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -33,3 +33,4 @@ from . import test_duplicate_detection_scope from . import test_apply_effective_mappings from . import test_frozen_value_normalisation +from . import test_frozen_detail_binding diff --git a/spp_change_request_v2/tests/test_frozen_detail_binding.py b/spp_change_request_v2/tests/test_frozen_detail_binding.py new file mode 100644 index 000000000..b5eb1fafe --- /dev/null +++ b/spp_change_request_v2/tests/test_frozen_detail_binding.py @@ -0,0 +1,95 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""A submitted change request with no detail row must still be repairable. + +``detail_res_id`` is frozen once a request leaves draft, so a substituted detail +cannot be attached after approval. The guard compared old against new without +distinguishing *binding* from *re-pointing*, so the legitimate False -> id +transition was refused too. ``_ensure_detail()`` performs exactly that +transition, and the guard has no sudo exemption, so a submitted request that +never got a detail row -- a type whose ``detail_model`` was configured after the +request was created, a row lost to a cascade, a request created through the API +without one -- could not be opened from any context. + +Binding is now allowed, but only to a row that already points back at this +request, so it cannot be used to attach someone else's detail. +""" + +from odoo.exceptions import UserError +from odoo.tests import tagged + +from .common import CRTestCase, get_or_create_cr_type + + +@tagged("post_install", "-at_install") +class TestFrozenDetailBinding(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.edit_type = get_or_create_cr_type(cls.env, "edit_individual") + + def _submitted_cr_without_detail(self): + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.get_detail() # materialise, then unbind while still in draft + cr.write({"detail_res_id": False}) + cr.sudo().write({"approval_state": "pending"}) + return cr + + # ------------------------------------------------------------------ + # The repair path must work + # ------------------------------------------------------------------ + + def test_ensure_detail_can_bind_after_submit(self): + cr = self._submitted_cr_without_detail() + detail = cr._ensure_detail() + self.assertTrue(detail, "_ensure_detail must be able to repair a submitted CR") + self.assertTrue(cr.detail_res_id) + self.assertEqual(detail.change_request_id, cr) + + def test_get_detail_works_after_repair(self): + cr = self._submitted_cr_without_detail() + cr._ensure_detail() + self.assertTrue(cr.get_detail()) + + # ------------------------------------------------------------------ + # Substitution must still be refused + # ------------------------------------------------------------------ + + def test_cannot_bind_a_detail_belonging_to_another_request(self): + other = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + foreign_detail = other.get_detail() + + cr = self._submitted_cr_without_detail() + with self.assertRaises(UserError): + cr.write({"detail_res_id": foreign_detail.id}) + + def test_cannot_repoint_an_already_bound_detail(self): + other = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + foreign_detail = other.get_detail() + + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.get_detail() + cr.sudo().write({"approval_state": "pending"}) + with self.assertRaises(UserError): + cr.write({"detail_res_id": foreign_detail.id}) + + def test_cannot_clear_an_already_bound_detail(self): + cr = self.CR.create( + {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} + ) + cr.get_detail() + cr.sudo().write({"approval_state": "pending"}) + with self.assertRaises(UserError): + cr.write({"detail_res_id": False}) + + def test_other_frozen_fields_are_unaffected(self): + cr = self._submitted_cr_without_detail() + with self.assertRaises(UserError): + cr.write({"selected_field_name": "given_name"}) From 78fed905f6b1babc3cdfe7b553e80e30f08fc4d8 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 13:38:04 +0700 Subject: [PATCH 16/18] fix: derive the change set with the same comparison apply uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conflict and duplicate detection decided whether a mapped field changed using _normalize_field_value, which lowercases and strips strings, while apply compares raw. A case- or whitespace-only edit was therefore invisible to detection yet still written to the registrant, so a field-scoped conflict rule could be sidestepped by a cosmetic edit. Detection also ignored transform expressions, which apply evaluates before comparing. The comparison moves onto the strategy as current_target_value / proposed_target_value / mapping_changes_value, and detection now asks it, so the two cannot drift apart again. Every configured mapping is still considered, not just the routed one: narrowing to selected_field_name would put the change set back under the requester's control. Similarity scoring keeps using _normalize_field_value — being case-insensitive is the point of a fuzzy match. Only the "did this change" derivation had to match apply. Also derives the caller's change set once per duplicate run instead of recomputing it for every candidate; it does not vary by candidate and each derivation re-browses the detail and re-reads every mapping. --- .../models/conflict_mixin.py | 29 +++- .../strategies/field_mapping.py | 56 ++++---- spp_change_request_v2/tests/__init__.py | 1 + .../tests/test_detection_matches_apply.py | 128 ++++++++++++++++++ 4 files changed, 185 insertions(+), 29 deletions(-) create mode 100644 spp_change_request_v2/tests/test_detection_matches_apply.py diff --git a/spp_change_request_v2/models/conflict_mixin.py b/spp_change_request_v2/models/conflict_mixin.py index cd616fc46..3bc622e7f 100644 --- a/spp_change_request_v2/models/conflict_mixin.py +++ b/spp_change_request_v2/models/conflict_mixin.py @@ -7,6 +7,9 @@ _logger = logging.getLogger(__name__) +_UNSET = object() + + class SPPCRConflictMixin(models.AbstractModel): """Mixin providing conflict and duplicate detection for change requests. @@ -330,14 +333,23 @@ def _proposed_changed_fields(self): if not detail or not registrant: return set() changed = set() + # Ask the apply strategy whether each mapping would write something, so + # detection and apply cannot disagree about what counts as a change. A + # local comparison here previously folded case and whitespace and + # ignored transform expressions, so a case-only edit was invisible to + # detection yet still written to the registrant. + # + # Every configured mapping is considered, NOT just the routed one: + # narrowing to ``selected_field_name`` would put the change set back + # under the requester's control, which is the bypass this derivation + # exists to close. + strategy = self.env["spp.cr.strategy.field_mapping"] for mapping in self.request_type_id.apply_mapping_ids: source_field = mapping.source_field target_field = mapping.target_field if source_field not in detail._fields or target_field not in registrant._fields: continue - detail_value = self._normalize_field_value(getattr(detail, source_field, None)) - registrant_value = self._normalize_field_value(getattr(registrant, target_field, None)) - if detail_value != registrant_value: + if strategy.mapping_changes_value(mapping, detail, registrant): changed.add(source_field) return changed @@ -465,8 +477,9 @@ def _detect_duplicates(self): candidates = self.env["spp.change.request"].search(domain) duplicates = [] + my_changed = self._proposed_changed_fields() for candidate in candidates: - similarity = self._calculate_similarity(candidate, config) + similarity = self._calculate_similarity(candidate, config, my_changed=my_changed) if similarity >= config.similarity_threshold: duplicates.append( { @@ -484,7 +497,7 @@ def _detect_duplicates(self): "status": "potential" if duplicates else "none", } - def _calculate_similarity(self, other_cr, config): + def _calculate_similarity(self, other_cr, config, my_changed=_UNSET): """Calculate similarity percentage between this CR and another. For dynamic-approval CRs, only the selected field (derived server-side @@ -508,7 +521,11 @@ def _calculate_similarity(self, other_cr, config): # Dynamic approval: compare only the fields actually changed (derived # server-side from the detail-vs-registrant diff, not a writable label). - my_changed = self._proposed_changed_fields() + # ``my_changed`` is derived once per duplicate run by the caller and + # passed in: it does not vary by candidate, and deriving it re-browses + # the detail and re-reads every mapping. + if my_changed is _UNSET: + my_changed = self._proposed_changed_fields() other_changed = other_cr._proposed_changed_fields() if my_changed is not None and other_changed is not None: # Score the fields BOTH requests actually propose to change. diff --git a/spp_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py index 648f790d3..cff7672f6 100644 --- a/spp_change_request_v2/strategies/field_mapping.py +++ b/spp_change_request_v2/strategies/field_mapping.py @@ -34,6 +34,35 @@ def _effective_mappings(self, change_request): return mappings.browse() return mappings.filtered(lambda m: m.source_field == selected) + def current_target_value(self, mapping, registrant): + """The registrant's current value for ``mapping``, as apply compares it.""" + value = getattr(registrant, mapping.target_field, None) + return value.id if hasattr(value, "id") else value + + def proposed_target_value(self, mapping, detail, registrant): + """The value ``mapping`` would write, transform included. + + Shared with conflict and duplicate detection so the two cannot disagree + about what counts as a change. Detection previously compared through + ``_normalize_field_value``, which lowercases and strips strings, while + apply compares raw and ignores no transform -- so a case- or + whitespace-only edit was invisible to detection yet still written, and a + transform could turn a differing value into an identical one (or the + reverse) with only apply aware of it. + """ + value = getattr(detail, mapping.source_field, None) + if hasattr(value, "id"): + value = value.id + if mapping.transform == "expression" and mapping.transform_expression: + value = self._eval_expression(mapping.transform_expression, value, detail, registrant) + return value + + def mapping_changes_value(self, mapping, detail, registrant): + """Whether ``mapping`` would write a different value than is stored.""" + return self.proposed_target_value(mapping, detail, registrant) != self.current_target_value( + mapping, registrant + ) + def apply(self, change_request): """Apply field mappings from detail to registrant.""" registrant = change_request.registrant_id @@ -73,34 +102,15 @@ def apply(self, change_request): values = {} for mapping in mappings: - source_value = getattr(detail, mapping.source_field, None) - current_value = getattr(registrant, mapping.target_field, None) - - # Handle relational fields - get ID - if hasattr(source_value, "id"): - source_value = source_value.id - if hasattr(current_value, "id"): - current_value = current_value.id - - # Apply transform if configured - if mapping.transform == "expression" and mapping.transform_expression: - source_value = self._eval_expression( - mapping.transform_expression, - source_value, - detail, - registrant, - ) + source_value = self.proposed_target_value(mapping, detail, registrant) + current_value = self.current_target_value(mapping, registrant) # Skip if value hasn't changed if source_value == current_value: continue - # Skip empty values (None, empty strings, empty collections) - # COMMENTED OUT: Users may want to intentionally clear fields - # if not self._is_value_empty(source_value, registrant, mapping.target_field): - # values[mapping.target_field] = source_value - - # Apply the value (including empty values for intentional clearing) + # Empty values are applied too (rather than skipped): a user may be + # intentionally clearing a field. values[mapping.target_field] = source_value if values: diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index ffca1a77e..8cf042cf8 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -34,3 +34,4 @@ from . import test_apply_effective_mappings from . import test_frozen_value_normalisation from . import test_frozen_detail_binding +from . import test_detection_matches_apply diff --git a/spp_change_request_v2/tests/test_detection_matches_apply.py b/spp_change_request_v2/tests/test_detection_matches_apply.py new file mode 100644 index 000000000..0a911d92d --- /dev/null +++ b/spp_change_request_v2/tests/test_detection_matches_apply.py @@ -0,0 +1,128 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Conflict/duplicate detection must judge "changed" exactly as apply does. + +The change set is derived from the detail-versus-registrant difference, and it +has to agree with the comparison the apply strategy makes. It did not: detection +compared through ``_normalize_field_value``, which lowercases and strips +strings, and it ignored transform expressions entirely, while apply compares raw +and applies the transform first. So a case- or whitespace-only edit was +invisible to detection yet still written to the registrant, and a transform +could make a differing value identical -- or the reverse -- with only apply +aware of it. + +Both sides now go through the strategy's ``mapping_changes_value``. Note this is +only about *whether* a field changed; similarity scoring stays deliberately +case-insensitive, since fuzzy matching is its purpose. +""" + +from odoo.tests import tagged + +from .common import CRTestCase + + +@tagged("post_install", "-at_install") +class TestDetectionMatchesApply(CRTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.registrant = cls.Partner.create( + { + "name": "Detect Apply Registrant", + "given_name": "John", + "family_name": "Fam", + "is_registrant": True, + "is_group": False, + } + ) + + def _make_type(self, code, transform=None): + mapping = {"source_field": "given_name", "target_field": "given_name"} + if transform: + mapping["transform"] = "expression" + mapping["transform_expression"] = transform + return self.CRType.create( + { + "code": code, + "name": code, + "target_type": "individual", + "detail_model": "spp.cr.detail.edit_individual", + "apply_strategy": "field_mapping", + "use_dynamic_approval": True, + "enable_duplicate_detection": True, + "apply_mapping_ids": [(0, 0, mapping)], + } + ) + + def _cr(self, cr_type, detail_vals): + cr = self.CR.create({"request_type_id": cr_type.id, "registrant_id": self.registrant.id}) + cr.get_detail().write(dict(detail_vals, field_to_modify="given_name")) + return cr + + def test_case_only_edit_is_seen_as_a_change(self): + """It is written to the registrant, so detection must see it.""" + cr_type = self._make_type("detect_apply_case") + cr = self._cr(cr_type, {"given_name": " john "}) + self.assertEqual( + cr._proposed_changed_fields(), + {"given_name"}, + "a value apply would write must count as a proposed change", + ) + + def test_detection_and_apply_agree_after_the_edit_lands(self): + cr_type = self._make_type("detect_apply_agree") + cr = self._cr(cr_type, {"given_name": " john "}) + self.env["spp.cr.strategy.field_mapping"].apply(cr) + self.assertEqual(self.registrant.given_name, " john ") + # Now the registrant holds it, so nothing is proposed any more. + self.assertEqual(cr._proposed_changed_fields(), set()) + + def test_identical_value_is_not_a_change(self): + cr_type = self._make_type("detect_apply_same") + cr = self._cr(cr_type, {"given_name": self.registrant.given_name}) + self.assertEqual(cr._proposed_changed_fields(), set()) + + def test_transform_detection_agrees_with_apply(self): + """Whatever a transform does, detection and apply must reach the same verdict. + + Transform expressions do not currently evaluate at all: Odoo 19's + ``safe_eval`` takes no ``nocopy`` argument, so ``_eval_expression`` + raises, logs a warning and returns the value untransformed. Rather than + pin either outcome, this asserts the invariant that matters -- the + derived change set agrees with whether apply actually wrote anything -- + which holds both before and after that is corrected. + """ + cr_type = self._make_type("detect_apply_tf_agree", transform="value + '-x'") + cr = self._cr(cr_type, {"given_name": "Jane"}) + changed = cr._proposed_changed_fields() + before = self.registrant.given_name + self.env["spp.cr.strategy.field_mapping"].apply(cr) + after = self.registrant.given_name + self.assertEqual( + bool(changed), + before != after, + "detection must agree with whether apply wrote something", + ) + + # ------------------------------------------------------------------ + # Deriving the change set once per run must not change the outcome + # ------------------------------------------------------------------ + + def test_passing_the_change_set_matches_deriving_it(self): + cr_type = self._make_type("detect_apply_memo") + config = self.env["spp.cr.duplicate.config"].create( + { + "name": "detect_apply_memo config", + "cr_type_id": cr_type.id, + "time_window_hours": 24, + "similarity_threshold": 70.0, + } + ) + cr_type.duplicate_detection_config_id = config + first = self._cr(cr_type, {"given_name": "Changed"}) + second = self._cr(cr_type, {"given_name": "Changed"}) + derived = second._calculate_similarity(first, config) + passed_in = second._calculate_similarity( + first, config, my_changed=second._proposed_changed_fields() + ) + self.assertEqual(derived, passed_in) + self.assertEqual(derived, 100.0) From 856cb448364643916f989a7ba0948a6c6fdb1f27 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 13:46:03 +0700 Subject: [PATCH 17/18] chore: bump versions and changelogs for the post-review fix batch spp_change_request_v2 -> 19.0.3.1.10 (detection/apply comparison, apply no-op rejection, detail re-binding, empty-string freeze normalisation, duplicate-run memoisation) and spp_programs -> 19.0.2.3.3 (operation lock on create). One bump per module rather than per fix: the per-fix versions in this batch came from each fix being its own PR, and these land together here. spp_programs takes 2.3.3, so the Tier-3 access-control follow-up moves to 2.3.4. --- spp_change_request_v2/README.rst | 39 ++++++++++ spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 8 ++ .../static/description/index.html | 76 ++++++++++++++----- .../strategies/field_mapping.py | 7 +- .../tests/test_detection_matches_apply.py | 4 +- .../tests/test_frozen_detail_binding.py | 20 ++--- .../tests/test_frozen_value_normalisation.py | 12 +-- spp_programs/README.rst | 13 ++++ spp_programs/__manifest__.py | 2 +- spp_programs/readme/HISTORY.md | 4 + spp_programs/static/description/index.html | 50 +++++++----- spp_programs/tests/test_force_unlock_authz.py | 49 +++++++----- 13 files changed, 195 insertions(+), 91 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index e214b8889..4ee4aba47 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,45 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.10 +~~~~~~~~~~~ + +- fix(security): conflict and duplicate detection now decide whether a + mapped field changed using the same comparison the apply strategy + uses. Detection compared through a helper that lowercases and strips + strings while apply compares raw, so a case- or whitespace-only edit + was invisible to detection yet still written to the registrant — + enough to sidestep a field-scoped conflict rule with a cosmetic edit. + Detection also ignored transform expressions, which apply evaluates + before comparing. Similarity scoring is unchanged and stays + case-insensitive, since that is the point of a fuzzy match. +- fix: applying a change request that has no mapping to write is now + rejected instead of reported as successful. Because + ``_effective_mappings`` fails closed, a request whose routed field + lost its mapping — or whose type has none configured — wrote nothing + yet was still stamped applied, with an applied date, an audit event + and a log line, so operators saw a green request whose change had been + silently dropped. A genuine no-op, where the registrant already holds + the proposed values, still applies cleanly. +- fix: a submitted change request with no detail row can be repaired + again. ``detail_res_id`` is frozen after submission so a substituted + detail cannot be attached post-approval, but the guard did not + distinguish binding from re-pointing, so ``_ensure_detail()`` could + not create the missing row and the request could not be opened from + any context. Binding is now accepted only for a row that already + points back at the request. +- fix: an empty string now reads as unset in the post-submit freeze. + Odoo stores an unset field as ``False`` while a JSON-RPC client or + integration re-saving a record sends ``""``, so an idempotent re-save + was rejected as though it had altered the approved content. Clearing a + populated frozen field with ``""`` is still rejected. The + normalisation existed verbatim on both the change request and the + detail base; it now lives once, so the two guards cannot disagree. +- perf: the caller's proposed-change set is derived once per + duplicate-detection run rather than recomputed for every candidate, + each derivation having re-browsed the detail and re-read every + configured mapping. + 19.0.3.1.9 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index cb75f19be..1f10e1b97 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.3.1.9", + "version": "19.0.3.1.10", "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/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 80dc932f4..3ffd3b06e 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,11 @@ +### 19.0.3.1.10 + +- fix(security): conflict and duplicate detection now decide whether a mapped field changed using the same comparison the apply strategy uses. Detection compared through a helper that lowercases and strips strings while apply compares raw, so a case- or whitespace-only edit was invisible to detection yet still written to the registrant — enough to sidestep a field-scoped conflict rule with a cosmetic edit. Detection also ignored transform expressions, which apply evaluates before comparing. Similarity scoring is unchanged and stays case-insensitive, since that is the point of a fuzzy match. +- fix: applying a change request that has no mapping to write is now rejected instead of reported as successful. Because `_effective_mappings` fails closed, a request whose routed field lost its mapping — or whose type has none configured — wrote nothing yet was still stamped applied, with an applied date, an audit event and a log line, so operators saw a green request whose change had been silently dropped. A genuine no-op, where the registrant already holds the proposed values, still applies cleanly. +- fix: a submitted change request with no detail row can be repaired again. `detail_res_id` is frozen after submission so a substituted detail cannot be attached post-approval, but the guard did not distinguish binding from re-pointing, so `_ensure_detail()` could not create the missing row and the request could not be opened from any context. Binding is now accepted only for a row that already points back at the request. +- fix: an empty string now reads as unset in the post-submit freeze. Odoo stores an unset field as `False` while a JSON-RPC client or integration re-saving a record sends `""`, so an idempotent re-save was rejected as though it had altered the approved content. Clearing a populated frozen field with `""` is still rejected. The normalisation existed verbatim on both the change request and the detail base; it now lives once, so the two guards cannot disagree. +- perf: the caller's proposed-change set is derived once per duplicate-detection run rather than recomputed for every candidate, each derivation having re-browsed the detail and re-read every configured mapping. + ### 19.0.3.1.9 - fix(security): duplicate detection now scores the fields both change requests actually propose to change, instead of demanding the two derived change sets be identical. Because a dynamic-approval type applies only the routed field, a requester could add a throwaway edit to another mapped field, make the two sets unequal and drop similarity to zero, while apply discarded that edit — so the evasion cost nothing. Similarity is now computed over the shared changed fields, proportionally, on the same scale the static path uses, which also stops a mostly identical multi-field request collapsing to zero as soon as one shared field differs. The change set is still derived from the detail-versus-registrant diff and never from the requester-writable `selected_field_name` / `field_to_modify`. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index f16bbf6b5..22feeaea5 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,46 @@

      Changelog

      +

      19.0.3.1.10

      +
        +
      • fix(security): conflict and duplicate detection now decide whether a +mapped field changed using the same comparison the apply strategy +uses. Detection compared through a helper that lowercases and strips +strings while apply compares raw, so a case- or whitespace-only edit +was invisible to detection yet still written to the registrant — +enough to sidestep a field-scoped conflict rule with a cosmetic edit. +Detection also ignored transform expressions, which apply evaluates +before comparing. Similarity scoring is unchanged and stays +case-insensitive, since that is the point of a fuzzy match.
      • +
      • fix: applying a change request that has no mapping to write is now +rejected instead of reported as successful. Because +_effective_mappings fails closed, a request whose routed field +lost its mapping — or whose type has none configured — wrote nothing +yet was still stamped applied, with an applied date, an audit event +and a log line, so operators saw a green request whose change had been +silently dropped. A genuine no-op, where the registrant already holds +the proposed values, still applies cleanly.
      • +
      • fix: a submitted change request with no detail row can be repaired +again. detail_res_id is frozen after submission so a substituted +detail cannot be attached post-approval, but the guard did not +distinguish binding from re-pointing, so _ensure_detail() could +not create the missing row and the request could not be opened from +any context. Binding is now accepted only for a row that already +points back at the request.
      • +
      • fix: an empty string now reads as unset in the post-submit freeze. +Odoo stores an unset field as False while a JSON-RPC client or +integration re-saving a record sends "", so an idempotent re-save +was rejected as though it had altered the approved content. Clearing a +populated frozen field with "" is still rejected. The +normalisation existed verbatim on both the change request and the +detail base; it now lives once, so the two guards cannot disagree.
      • +
      • perf: the caller’s proposed-change set is derived once per +duplicate-detection run rather than recomputed for every candidate, +each derivation having re-browsed the detail and re-read every +configured mapping.
      • +
      +
      +

      19.0.3.1.9

      • fix(security): duplicate detection now scores the fields both change @@ -1355,7 +1395,7 @@

        19.0.3.1.9

        requester-writable selected_field_name / field_to_modify.
      -
      +

      19.0.3.1.8

      • fix(security): scope the Create-Group member wizards to the parent @@ -1373,7 +1413,7 @@

        19.0.3.1.8

        access-control entry grants.
      -
      +

      19.0.3.1.7

      • fix(security): require change-request manager rights to apply a change @@ -1388,7 +1428,7 @@

        19.0.3.1.7

        endpoint.
      -
      +

      19.0.3.1.6

      • fix(security): derive conflict and duplicate detection from the change @@ -1402,7 +1442,7 @@

        19.0.3.1.6

        an empty one, so detection cannot silently disable itself.
      -
      +

      19.0.3.1.5

      • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1414,7 +1454,7 @@

        19.0.3.1.5

        are noupdate.
      -
      +

      19.0.3.1.4

      • fix(security): add ownership and area record rules to every concrete @@ -1431,7 +1471,7 @@

        19.0.3.1.4

        unrestricted delete their access-control entries grant.
      -
      +

      19.0.3.1.3

      • fix(security): route and apply the same single field for @@ -1444,7 +1484,7 @@

        19.0.3.1.3

        the routing selector.
      -
      +

      19.0.3.1.2

      • fix(change_request_v2): adding an ID now looks for a live one of that @@ -1453,7 +1493,7 @@

        19.0.3.1.2

        (#1136)
      -
      +

      19.0.3.1.1

      • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1467,7 +1507,7 @@

        19.0.3.1.1

        applied) so the constraint applies cleanly on upgrade.
      -
      +

      19.0.3.1.0

      • revert(change_request): restore the create-a-new-individual Add @@ -1485,7 +1525,7 @@

        19.0.3.1.0

        not restored here; reinstate separately if needed.
      -
      +

      19.0.3.0.0

      • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1507,7 +1547,7 @@

        19.0.3.0.0

        must adapt (see #1133).
      -
      +

      19.0.2.0.8

      • fix(views): disable inline creation of CR document types on the Change @@ -1518,7 +1558,7 @@

        19.0.2.0.8

        Documents” modal (missing Name field) that blocked saving (#1125)
      -
      +

      19.0.2.0.7

      • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1530,7 +1570,7 @@

        19.0.2.0.7

        dependencies.
      -
      +

      19.0.2.0.6

      • fix(views): route post-submit CRs (pending / approved / applied / @@ -1545,7 +1585,7 @@

        19.0.2.0.6

        list so row-click goes through the stage router.
      -
      +

      19.0.2.0.5

      • fix(security): add a global ir.rule on spp.change.request that @@ -1558,27 +1598,27 @@

        19.0.2.0.5

        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_change_request_v2/strategies/field_mapping.py b/spp_change_request_v2/strategies/field_mapping.py index cff7672f6..88c09f997 100644 --- a/spp_change_request_v2/strategies/field_mapping.py +++ b/spp_change_request_v2/strategies/field_mapping.py @@ -59,9 +59,7 @@ def proposed_target_value(self, mapping, detail, registrant): def mapping_changes_value(self, mapping, detail, registrant): """Whether ``mapping`` would write a different value than is stored.""" - return self.proposed_target_value(mapping, detail, registrant) != self.current_target_value( - mapping, registrant - ) + return self.proposed_target_value(mapping, detail, registrant) != self.current_target_value(mapping, registrant) def apply(self, change_request): """Apply field mappings from detail to registrant.""" @@ -127,8 +125,7 @@ def apply(self, change_request): registrant.name_change() else: _logger.info( - "Field mapping for CR %s wrote nothing: the registrant already holds the " - "proposed values.", + "Field mapping for CR %s wrote nothing: the registrant already holds the proposed values.", change_request.name, ) diff --git a/spp_change_request_v2/tests/test_detection_matches_apply.py b/spp_change_request_v2/tests/test_detection_matches_apply.py index 0a911d92d..4b70c3383 100644 --- a/spp_change_request_v2/tests/test_detection_matches_apply.py +++ b/spp_change_request_v2/tests/test_detection_matches_apply.py @@ -121,8 +121,6 @@ def test_passing_the_change_set_matches_deriving_it(self): first = self._cr(cr_type, {"given_name": "Changed"}) second = self._cr(cr_type, {"given_name": "Changed"}) derived = second._calculate_similarity(first, config) - passed_in = second._calculate_similarity( - first, config, my_changed=second._proposed_changed_fields() - ) + passed_in = second._calculate_similarity(first, config, my_changed=second._proposed_changed_fields()) self.assertEqual(derived, passed_in) self.assertEqual(derived, 100.0) diff --git a/spp_change_request_v2/tests/test_frozen_detail_binding.py b/spp_change_request_v2/tests/test_frozen_detail_binding.py index b5eb1fafe..1b6da6a3d 100644 --- a/spp_change_request_v2/tests/test_frozen_detail_binding.py +++ b/spp_change_request_v2/tests/test_frozen_detail_binding.py @@ -28,9 +28,7 @@ def setUpClass(cls): cls.edit_type = get_or_create_cr_type(cls.env, "edit_individual") def _submitted_cr_without_detail(self): - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.get_detail() # materialise, then unbind while still in draft cr.write({"detail_res_id": False}) cr.sudo().write({"approval_state": "pending"}) @@ -57,9 +55,7 @@ def test_get_detail_works_after_repair(self): # ------------------------------------------------------------------ def test_cannot_bind_a_detail_belonging_to_another_request(self): - other = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + other = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) foreign_detail = other.get_detail() cr = self._submitted_cr_without_detail() @@ -67,23 +63,17 @@ def test_cannot_bind_a_detail_belonging_to_another_request(self): cr.write({"detail_res_id": foreign_detail.id}) def test_cannot_repoint_an_already_bound_detail(self): - other = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + other = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) foreign_detail = other.get_detail() - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.get_detail() cr.sudo().write({"approval_state": "pending"}) with self.assertRaises(UserError): cr.write({"detail_res_id": foreign_detail.id}) def test_cannot_clear_an_already_bound_detail(self): - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.get_detail() cr.sudo().write({"approval_state": "pending"}) with self.assertRaises(UserError): diff --git a/spp_change_request_v2/tests/test_frozen_value_normalisation.py b/spp_change_request_v2/tests/test_frozen_value_normalisation.py index a30205d37..085c4ea94 100644 --- a/spp_change_request_v2/tests/test_frozen_value_normalisation.py +++ b/spp_change_request_v2/tests/test_frozen_value_normalisation.py @@ -56,9 +56,7 @@ def test_zero_is_not_treated_as_unset(self): # ------------------------------------------------------------------ def test_empty_string_for_an_unset_frozen_field_is_accepted(self): - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.sudo().write({"approval_state": "pending"}) self.assertFalse(cr.selected_field_old_value, "test assumes the field is unset") # An integration re-saving the record sends "" for the empty Char. @@ -66,18 +64,14 @@ def test_empty_string_for_an_unset_frozen_field_is_accepted(self): self.assertFalse(cr.selected_field_old_value) def test_a_real_change_to_a_frozen_field_is_still_rejected(self): - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.sudo().write({"approval_state": "pending"}) with self.assertRaises(UserError): cr.write({"selected_field_old_value": "something else"}) def test_clearing_a_populated_frozen_field_is_still_rejected(self): """'' must read as unset, not as a licence to clear a set value.""" - cr = self.CR.create( - {"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id} - ) + cr = self.CR.create({"request_type_id": self.edit_type.id, "registrant_id": self.test_individual.id}) cr.write({"selected_field_old_value": "Original"}) cr.sudo().write({"approval_state": "pending"}) with self.assertRaises(UserError): diff --git a/spp_programs/README.rst b/spp_programs/README.rst index a853ecd58..03263225f 100644 --- a/spp_programs/README.rst +++ b/spp_programs/README.rst @@ -254,6 +254,19 @@ Dependencies Changelog ========= +19.0.2.3.3 +~~~~~~~~~~ + +- fix(security): the operation lock is now guarded on create as well as + write. Restricting only writes to ``is_locked`` / ``locked_reason`` + left creation unguarded, so a program officer could create a cycle or + program already locked and bypass the check entirely — and then could + not clear the lock again, since clearing it goes through the guarded + write, leaving a self-inflicted lockout only a system administrator + could undo. The check is shared by both paths; ``sudo()`` and system + administrators are unaffected, so the async pipeline keeps managing + the lock as before. + 19.0.2.3.2 ~~~~~~~~~~ diff --git a/spp_programs/__manifest__.py b/spp_programs/__manifest__.py index 001f08aff..4e4cda8b8 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.3.2", + "version": "19.0.2.3.3", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_programs/readme/HISTORY.md b/spp_programs/readme/HISTORY.md index 688526a9c..ddc705e4c 100644 --- a/spp_programs/readme/HISTORY.md +++ b/spp_programs/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.3.3 + +- fix(security): the operation lock is now guarded on create as well as write. Restricting only writes to `is_locked` / `locked_reason` left creation unguarded, so a program officer could create a cycle or program already locked and bypass the check entirely — and then could not clear the lock again, since clearing it goes through the guarded write, leaving a self-inflicted lockout only a system administrator could undo. The check is shared by both paths; `sudo()` and system administrators are unaffected, so the async pipeline keeps managing the lock as before. + ### 19.0.2.3.2 - fix(security): the Program Viewer role no longer carries the Tier-2 `spp_registry.group_registry_viewer` group, which gates the standalone Registry Search portal menu and exposed a broad registrant-PII enumeration surface to a read-only program role. It now uses the Tier-3 `spp_registry.group_registry_read` group instead, preserving the registrant read needed for program cross-references (same read ACLs, defined in `spp_base_common`) without the Registry app menu. Includes a migration that re-points the role and re-syncs already-assigned users on upgrade. diff --git a/spp_programs/static/description/index.html b/spp_programs/static/description/index.html index d7aaf44b7..231476114 100644 --- a/spp_programs/static/description/index.html +++ b/spp_programs/static/description/index.html @@ -658,6 +658,20 @@

        Changelog

      +

      19.0.2.3.3

      +
        +
      • fix(security): the operation lock is now guarded on create as well as +write. Restricting only writes to is_locked / locked_reason +left creation unguarded, so a program officer could create a cycle or +program already locked and bypass the check entirely — and then could +not clear the lock again, since clearing it goes through the guarded +write, leaving a self-inflicted lockout only a system administrator +could undo. The check is shared by both paths; sudo() and system +administrators are unaffected, so the async pipeline keeps managing +the lock as before.
      • +
      +
      +

      19.0.2.3.2

      • fix(security): the Program Viewer role no longer carries the Tier-2 @@ -671,7 +685,7 @@

        19.0.2.3.2

        already-assigned users on upgrade.
      -
      +

      19.0.2.3.1

      • fix(security): make the async operation lock a server-side boundary. @@ -688,7 +702,7 @@

        19.0.2.3.1

        acquire/release from the initiating user keeps working.
      -
      +

      19.0.2.3.0

      • feat(spp_programs): Duplicate Detection is a card with an Add @@ -713,7 +727,7 @@

        19.0.2.3.0

        still blocked its own re-adding (#1171)
      -
      +

      19.0.2.2.1

      • fix(spp_programs): stop Enroll Eligible undoing a deliberate pause. A @@ -723,7 +737,7 @@

        19.0.2.2.1

        Pausing is a decision that only Resume reverses (#1117)
      -
      +

      19.0.2.1.3

      • fix(security): align Program Viewer / Validator / Cycle Approver roles @@ -742,7 +756,7 @@

        19.0.2.1.3

        cross-references — only the dedicated top-level menu disappears.
      -
      +

      19.0.2.1.2

      • fix(security): add global ir.rule records on @@ -756,7 +770,7 @@

        19.0.2.1.2

        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 @@ -765,7 +779,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 @@ -776,7 +790,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, @@ -789,7 +803,7 @@

        19.0.2.0.10

        submission on double-click
      -
      +

      19.0.2.0.9

      • Add context flags (skip_registrant_statistics, @@ -802,7 +816,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 @@ -813,7 +827,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 @@ -822,7 +836,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)
      • @@ -831,34 +845,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 @@ -867,7 +881,7 @@

        19.0.2.0.1

        constraint creation
      -
      +

      19.0.2.0.0

      • Initial migration to OpenSPP2
      • diff --git a/spp_programs/tests/test_force_unlock_authz.py b/spp_programs/tests/test_force_unlock_authz.py index 1c7fa058f..5fddeb92d 100644 --- a/spp_programs/tests/test_force_unlock_authz.py +++ b/spp_programs/tests/test_force_unlock_authz.py @@ -230,32 +230,39 @@ def test_cycle_creation_without_lock_fields_still_works(self): def test_cycle_can_be_created_locked_by_system_admin(self): today = fields.Date.today() - cycle = self.env["spp.cycle"].with_user(self.system).create( - { - "name": f"Admin Locked {uuid.uuid4().hex[:8]}", - "program_id": self.program.id, - "sequence": 4, - "start_date": today, - "end_date": fields.Date.add(today, days=30), - "is_locked": True, - "locked_reason": "admin set at create", - } + cycle = ( + self.env["spp.cycle"] + .with_user(self.system) + .create( + { + "name": f"Admin Locked {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 4, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "is_locked": True, + "locked_reason": "admin set at create", + } + ) ) self.assertTrue(cycle.is_locked) def test_cycle_can_be_created_locked_via_sudo(self): """The async pipeline creates through sudo() and must stay unaffected.""" today = fields.Date.today() - cycle = self.env["spp.cycle"].sudo().create( - { - "name": f"Sudo Locked {uuid.uuid4().hex[:8]}", - "program_id": self.program.id, - "sequence": 5, - "start_date": today, - "end_date": fields.Date.add(today, days=30), - "is_locked": True, - "locked_reason": "pipeline", - } + cycle = ( + self.env["spp.cycle"] + .sudo() + .create( + { + "name": f"Sudo Locked {uuid.uuid4().hex[:8]}", + "program_id": self.program.id, + "sequence": 5, + "start_date": today, + "end_date": fields.Date.add(today, days=30), + "is_locked": True, + "locked_reason": "pipeline", + } + ) ) self.assertTrue(cycle.is_locked) - From 3079d76d4a8844175bad85944606cd6de7e199e4 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Wed, 26 Aug 2026 13:55:57 +0700 Subject: [PATCH 18/18] chore: annotate the intentional sudo in the detail-binding check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit semgrep's odoo-sudo-without-context flagged the ownership lookup added for re-binding a detail row. The sudo is deliberate: the caller may hold no access to the detail model, and the result is only ever used to accept or reject the write — the record is never returned or exposed. Annotated in the same way as the other intentional sudo calls in this module, with the reasoning inline. --- spp_change_request_v2/models/change_request.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index ac4c4d0f9..6a703e54e 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -697,7 +697,10 @@ def _detail_row_belongs_to_self(self, detail_id): parent_field = "x_change_request_id" if "x_change_request_id" in model._fields else "change_request_id" if parent_field not in model._fields: return False - detail = model.sudo().browse(int(detail_id)).exists() + # sudo: the caller may hold no access to the detail model, and the answer + # is only ever used to accept or reject the write -- the record itself is + # never returned or exposed. Reads a single field on a single row. + detail = model.sudo().browse(int(detail_id)).exists() # nosemgrep: odoo-sudo-without-context return bool(detail) and detail[parent_field].id == self.id def _alters_frozen_field(self, field, value):
      Field