diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst
index 97adb511a..832ac30e6 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.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/models/change_request.py b/spp_change_request_v2/models/change_request.py
index d1b1012ee..1f45ddfbe 100644
--- a/spp_change_request_v2/models/change_request.py
+++ b/spp_change_request_v2/models/change_request.py
@@ -625,6 +625,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..24fd48d76 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,64 @@ 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
+
+ @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 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..8ac2b997b 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.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
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")