From d42b686410ab6df61c1d7b20a89c2f0f95e3dc97 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 11:07:41 +0800 Subject: [PATCH 1/5] security(cr): apply only the selected field for dynamic-approval CRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic-approval CR types route and approve based on a single selected field (selected_field_name), but the field_mapping apply strategy iterated over ALL apply_mapping_ids and wrote every changed detail field to the registrant under sudo(). A CR user could select a low-risk field (routing to a weak/local approval), also change high-risk mapped fields on the detail, obtain the weak approval, and have every changed field written — a privilege-escalation bypass of the tiered approval routing. (field_mapping also treats empty detail values as intentional clears, so non-selected fields could even be wiped.) Restrict field_mapping to the selected field's mapping for dynamic-approval CR types, in both apply() and preview() (so the approver preview matches what is written). Fail closed: a missing or unmapped selection applies nothing. Non-dynamic CR types are unchanged. The custom apply strategy is out of scope (author-controlled writes). Tests: a dynamic CR applies/previews only the selected field even when another mapped field changed; an unmapped selection writes nothing; non-dynamic CRs still apply all changed mappings. spp_change_request_v2 302/302. --- .../strategies/field_mapping.py | 27 +++++- .../tests/test_dynamic_approval.py | 84 +++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) 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..b2ddebb78 100644 --- a/spp_change_request_v2/tests/test_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -1057,3 +1057,87 @@ 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") From 63242a07f68774e7ab79341a1295e7ca77152a8a Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 11:46:36 +0800 Subject: [PATCH 2/5] security(cr): freeze proposed change + detail pointer once submitted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the apply-time restriction: close the time-of-check/time-of-use desync where the routed change stayed mutable after submission. A user could route on a low-risk field (weak approval), then before apply either swap the selected field, change the routed field's value, or substitute the detail record — applying an unapproved field/value under the weaker approval. The views already made these readonly once approval_state left draft/revision; this enforces the same on the server so it cannot be bypassed via RPC. - spp.cr.detail.base.write(): reject changes to proposed-change fields (field_to_modify + field_mapping source fields) when the CR is not in draft/revision. Apply-output fields (created_*_id) are not protected, so apply strategies still record results post-approval. - spp.change.request.write(): once submitted, reject changes to selected_field_name/old/new value AND detail_res_id/detail_res_model. get_detail() resolves the detail strictly by that pointer for both routing and apply, so freezing it prevents substituting a second detail record. Editing requires resetting to draft, which re-routes the approval. Tests: field swap, direct selected_field_name write, value swap, and detail substitution are all rejected post-submit; draft edits still allowed. spp_change_request_v2 307/307; spp_cr_type_assign_program 19/19, spp_farmer_registry_cr 95/95 (custom detail types' created_* writes unaffected). --- .../models/change_request.py | 32 +++++++++++ .../models/change_request_detail_base.py | 44 +++++++++++++++ .../tests/test_dynamic_approval.py | 56 ++++++++++++++++++- 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index d1b1012ee..323573793 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -625,6 +625,38 @@ 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", + ) + + def write(self, vals): + guarded = [f for f in self._FROZEN_ON_SUBMIT_FIELDS if f in vals] + if guarded: + for rec in self: + if rec.approval_state in ("draft", "revision") or not rec.approval_state: + continue + if any(vals[f] != 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..c57731a98 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,51 @@ 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. + """ + 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 + + 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 + current = rec[field_name] + if hasattr(current, "id"): + current = current.id + if vals[field_name] != current: + 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/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py index b2ddebb78..b5540e44a 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__) @@ -1141,3 +1141,57 @@ def test_non_dynamic_apply_still_writes_all_changed_fields(self): 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_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 05537b49bbe4235af3a17ac9368e44873864b357 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 13:00:26 +0800 Subject: [PATCH 3/5] fix(cr): normalize frozen-field comparison to avoid false-positive lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Gemini review on PR #264: the post-submit freeze guards compared the write payload to the stored value directly. Odoo stores unset fields as False, but a JSON-RPC/integration payload may send None for the same field, and a Many2one may be written as a recordset — so an idempotent re-save (None vs False, or recordset vs the id-normalized current) was wrongly treated as a change and locked out. Normalize both sides (recordset -> id, None -> False) in both the CR and detail guards via a shared _normalize_frozen_value helper. Real changes still differ and are still blocked. Test: writing None to an already-unset protected field post-submit no longer raises. spp_change_request_v2 308/308. --- .../models/change_request.py | 16 +++++++++++++- .../models/change_request_detail_base.py | 21 +++++++++++++++---- .../tests/test_dynamic_approval.py | 10 +++++++++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 323573793..1f45ddfbe 100644 --- a/spp_change_request_v2/models/change_request.py +++ b/spp_change_request_v2/models/change_request.py @@ -641,13 +641,27 @@ 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 for rec in self: if rec.approval_state in ("draft", "revision") or not rec.approval_state: continue - if any(vals[f] != rec[f] for f in guarded): + 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 " 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 c57731a98..24fd48d76 100644 --- a/spp_change_request_v2/models/change_request_detail_base.py +++ b/spp_change_request_v2/models/change_request_detail_base.py @@ -88,6 +88,19 @@ 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. @@ -103,10 +116,10 @@ def _assert_content_editable(self, vals): for field_name in rec._protected_content_fields(change_request): if field_name not in vals or field_name not in rec._fields: continue - current = rec[field_name] - if hasattr(current, "id"): - current = current.id - if vals[field_name] != current: + # 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, " diff --git a/spp_change_request_v2/tests/test_dynamic_approval.py b/spp_change_request_v2/tests/test_dynamic_approval.py index b5540e44a..ec48c4ec5 100644 --- a/spp_change_request_v2/tests/test_dynamic_approval.py +++ b/spp_change_request_v2/tests/test_dynamic_approval.py @@ -1185,6 +1185,16 @@ def test_cannot_repoint_detail_after_submit(self): 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).""" From 45c80635ee2ec990c21bb5a6b3d68fa80daa18f5 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Thu, 2 Jul 2026 22:31:26 +0800 Subject: [PATCH 4/5] chore(spp_change_request_v2): bump version to 19.0.2.0.8 + changelog Record the dynamic-approval single-field apply + post-submit freeze hardening in the version and HISTORY changelog. --- spp_change_request_v2/README.rst | 43 ++++++++++++------- spp_change_request_v2/__manifest__.py | 2 +- spp_change_request_v2/readme/HISTORY.md | 4 ++ .../static/description/index.html | 28 ++++++++---- 4 files changed, 52 insertions(+), 25 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 97adb511a..a11b76e4c 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -752,22 +752,22 @@ Methods available for override on detail models (all inherited from Related fields available on all detail models (from ``spp.cr.detail.base``): -+--------------------------+-----------+------------------------------------------------------------+ -| Field | Type | Source | -+==========================+===========+============================================================+ -| ``change_request_id`` | Many2one | Direct link to parent CR | -+--------------------------+-----------+------------------------------------------------------------+ -| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``approval_state`` | Selection | ``change_request_id.approval_state`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | -+--------------------------+-----------+------------------------------------------------------------+ -| ``field_to_modify`` | Selection | Dynamic field selector (populated by | -| | | ``_get_field_to_modify_selection``) | -+--------------------------+-----------+------------------------------------------------------------+ ++----------------------------+-----------+------------------------------------------------------------+ +| Field | Type | Source | ++============================+===========+============================================================+ +| ``change_request_id`` | Many2one | Direct link to parent CR | ++----------------------------+-----------+------------------------------------------------------------+ +| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``approval_state`` | Selection | ``change_request_id.approval_state`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | ++----------------------------+-----------+------------------------------------------------------------+ +| ``field_to_modify`` | Selection | Dynamic field selector (populated by | +| | | ``_get_field_to_modify_selection``) | ++----------------------------+-----------+------------------------------------------------------------+ CR Type Fields Reference ~~~~~~~~~~~~~~~~~~~~~~~~ @@ -853,6 +853,17 @@ Before declaring a new CR type complete: Changelog ========= +19.0.2.0.8 +~~~~~~~~~~ + +- fix(security): for dynamic-approval CR types, the ``field_mapping`` + apply strategy now writes only the routed/approved field, and the + proposed change is frozen once submitted (selected field, mapped field + values, and the detail record pointer). Previously a user could route + a low-risk field to a weak approval and smuggle changes to other + mapped fields — or swap the field/value/detail after routing — so + unapproved changes reached the registrant. + 19.0.2.0.7 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 3a26a2c99..07ffc94e2 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.2.0.7", + "version": "19.0.2.0.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 8644831eb..2be7dd51b 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.8 + +- fix(security): for dynamic-approval CR types, the `field_mapping` apply strategy now writes only the routed/approved field, and the proposed change is frozen once submitted (selected field, mapped field values, and the detail record pointer). Previously a user could route a low-risk field to a weak approval and smuggle changes to other mapped fields — or swap the field/value/detail after routing — so unapproved changes reached the registrant. + ### 19.0.2.0.7 - fix(security): align CR Requestor / CR Local Validator / CR HQ Validator roles with the OP#951 menu audit — replace the `spp_registry.group_registry_read` (Tier-3, no menu) link with `spp_registry.group_registry_viewer` so these roles see the Registry menu; add `spp_hazard.group_hazard_viewer` so they retain Hazard visibility once the menu root is gated. Adds `spp_hazard` to module dependencies. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index ef26ac45e..948f45234 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,18 @@

Changelog

+

19.0.2.0.8

+
    +
  • fix(security): for dynamic-approval CR types, the field_mapping +apply strategy now writes only the routed/approved field, and the +proposed change is frozen once submitted (selected field, mapped field +values, and the detail record pointer). Previously a user could route +a low-risk field to a weak approval and smuggle changes to other +mapped fields — or swap the field/value/detail after routing — so +unapproved changes reached the registrant.
  • +
+
+

19.0.2.0.7

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

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1365,7 +1377,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 @@ -1378,27 +1390,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
  • From fdae8e87fe5f0144b7add88e77ba2a485d3a29fe Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 3 Jul 2026 00:30:09 +0800 Subject: [PATCH 5/5] docs(spp_change_request_v2): normalize README table widths to match CI generator The earlier README regeneration was run in a local env whose RST renderer produced wider (28-char) table columns than CI's pinned oca-gen environment (26-char), causing the 'Generate addons README files from fragments' pre-commit hook to fail on CI. Restore the column widths CI's generator produces so the generated docs match the fragments. --- spp_change_request_v2/README.rst | 32 +++++++++---------- .../static/description/index.html | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index a11b76e4c..832ac30e6 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -752,22 +752,22 @@ Methods available for override on detail models (all inherited from Related fields available on all detail models (from ``spp.cr.detail.base``): -+----------------------------+-----------+------------------------------------------------------------+ -| Field | Type | Source | -+============================+===========+============================================================+ -| ``change_request_id`` | Many2one | Direct link to parent CR | -+----------------------------+-----------+------------------------------------------------------------+ -| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``approval_state`` | Selection | ``change_request_id.approval_state`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | -+----------------------------+-----------+------------------------------------------------------------+ -| ``field_to_modify`` | Selection | Dynamic field selector (populated by | -| | | ``_get_field_to_modify_selection``) | -+----------------------------+-----------+------------------------------------------------------------+ ++--------------------------+-----------+------------------------------------------------------------+ +| Field | Type | Source | ++==========================+===========+============================================================+ +| ``change_request_id`` | Many2one | Direct link to parent CR | ++--------------------------+-----------+------------------------------------------------------------+ +| ``registrant_id`` | Many2one | ``change_request_id.registrant_id`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``approval_state`` | Selection | ``change_request_id.approval_state`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``is_applied`` | Boolean | ``change_request_id.is_applied`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``use_dynamic_approval`` | Boolean | ``change_request_id.request_type_id.use_dynamic_approval`` | ++--------------------------+-----------+------------------------------------------------------------+ +| ``field_to_modify`` | Selection | Dynamic field selector (populated by | +| | | ``_get_field_to_modify_selection``) | ++--------------------------+-----------+------------------------------------------------------------+ CR Type Fields Reference ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 948f45234..8ac2b997b 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
-+-+
Field