diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 914b1a0e5..c66f77ccc 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.0.4 +~~~~~~~~~~ + +- fix(security): enforce manager authorization server-side when applying + a change request. ``action_apply()`` runs the apply strategy under + sudo (which can write ``spp.group.membership`` and other models CR + roles cannot), and the manager restriction previously existed only on + the review button — but Odoo object methods are callable over RPC, so + a ``group_cr_user`` could invoke ``action_apply()`` directly on an + approved change request and drive superuser membership writes (e.g. + remove/transfer member). The public ``action_apply()`` now requires + ``group_cr_manager`` (or a superuser/sudo context); the apply + mechanism was moved to an internal, non-RPC + ``_apply_change_request()`` that auto-apply-on-approve continues to + use so validator-driven approvals still apply. + 19.0.3.0.0 ~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index 1e32f323f..5616eb71d 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.0.0", + "version": "19.0.3.0.4", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", diff --git a/spp_change_request_v2/models/change_request.py b/spp_change_request_v2/models/change_request.py index 565185bad..87ea06966 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__) @@ -1020,7 +1020,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) @@ -1365,32 +1369,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 0f9d181e5..ee3ed6021 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,17 @@ +### 19.0.3.0.4 + +- fix(security): enforce manager authorization server-side when applying a + change request. ``action_apply()`` runs the apply strategy under sudo (which + can write ``spp.group.membership`` and other models CR roles cannot), and the + manager restriction previously existed only on the review button — but Odoo + object methods are callable over RPC, so a ``group_cr_user`` could invoke + ``action_apply()`` directly on an approved change request and drive superuser + membership writes (e.g. remove/transfer member). The public ``action_apply()`` + now requires ``group_cr_manager`` (or a superuser/sudo context); the apply + mechanism was moved to an internal, non-RPC ``_apply_change_request()`` that + auto-apply-on-approve continues to use so validator-driven approvals still + apply. + ### 19.0.3.0.0 - feat(change_request): redesign the group/membership CR flows (#242) — Create Group (#876), Add Member now searches an existing member (#871), Remove Member first-page/review cleanup (#872), Change Head of Household via a per-member role table (#873), and Split Household as a relational member move with single-head validation (#877). Review pages render the real data as tables / detail sections. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index 58f8d5751..9f25b0014 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,23 @@