diff --git a/spp_graduation/README.rst b/spp_graduation/README.rst index 430b8e472..42b600376 100644 --- a/spp_graduation/README.rst +++ b/spp_graduation/README.rst @@ -632,6 +632,18 @@ Test 10: Edge Cases Changelog ========= +19.0.2.0.2 +~~~~~~~~~~ + +- fix(security): enforce manager-only approval of graduation assessments + server-side. ``action_approve`` / ``action_reject`` / + ``action_reset_draft`` now require the graduation manager group (the + view-level button gating was UI-only and bypassable via RPC), and + non-managers can no longer set the approval fields (``approved_by_id`` + / ``approved_date`` / ``graduation_date``), move ``state`` beyond + ``draft → submitted``, or edit a submitted assessment's content. + Prevents a user from self-approving their own assessment. + 19.0.2.0.1 ~~~~~~~~~~ diff --git a/spp_graduation/__manifest__.py b/spp_graduation/__manifest__.py index c0b3fb07d..3d88a3005 100644 --- a/spp_graduation/__manifest__.py +++ b/spp_graduation/__manifest__.py @@ -2,7 +2,7 @@ { "name": "OpenSPP Graduation Management", "summary": "Manage graduation and exit from time-bound social protection programs", - "version": "19.0.2.0.1", + "version": "19.0.2.0.2", "category": "OpenSPP", "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_graduation/models/graduation_assessment.py b/spp_graduation/models/graduation_assessment.py index bdcce3e19..6e87f4242 100644 --- a/spp_graduation/models/graduation_assessment.py +++ b/spp_graduation/models/graduation_assessment.py @@ -1,7 +1,7 @@ from dateutil.relativedelta import relativedelta from odoo import _, api, fields, models -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import AccessError, UserError, ValidationError class GraduationAssessment(models.Model): @@ -136,6 +136,36 @@ def _compute_monitoring_end(self): else: rec.monitoring_end_date = False + # Fields only a graduation manager may set — they record the approval + # outcome and must never be writable by the assessor via RPC. + _MANAGER_ONLY_FIELDS = ("approved_by_id", "approved_date", "graduation_date") + + # Assessor-editable content. Once the assessment leaves draft it is awaiting + # the manager's decision, so a non-manager must not change what will be + # approved (e.g. flip recommendation to "graduate" after submitting). Only + # these business fields are frozen — technical/chatter writes are unaffected. + _LOCKED_CONTENT_FIELDS = ( + "partner_id", + "pathway_id", + "assessment_date", + "recommendation", + "recommendation_notes", + "response_ids", + ) + + def _is_graduation_manager(self): + """True for graduation managers and for superuser/sudo (system) contexts. + + Approve/reject/reset are manager-only. The superuser/sudo exemption keeps + programmatic and system flows working; real end users are always checked + against the manager group. + """ + return self.env.su or self.env.user.has_group("spp_graduation.group_spp_graduation_manager") + + def _ensure_graduation_manager(self): + if not self._is_graduation_manager(): + raise AccessError(_("Only graduation managers can approve, reject, or reset graduation assessments.")) + def action_submit(self): for rec in self: if rec.state != "draft": @@ -143,6 +173,7 @@ def action_submit(self): rec.state = "submitted" def action_approve(self): + self._ensure_graduation_manager() for rec in self: if rec.state != "submitted": raise UserError(_("Only submitted assessments can be approved.")) @@ -157,17 +188,68 @@ def action_approve(self): rec.graduation_date = fields.Date.today() def action_reject(self): + self._ensure_graduation_manager() for rec in self: if rec.state != "submitted": raise UserError(_("Only submitted assessments can be rejected.")) rec.state = "rejected" def action_reset_draft(self): + self._ensure_graduation_manager() for rec in self: if rec.state not in ("submitted", "rejected"): raise UserError(_("Only submitted or rejected assessments can be reset to draft.")) rec.state = "draft" + @api.model_create_multi + def create(self, vals_list): + # A non-manager may only create draft assessments and may not preset the + # approval outcome fields (blocks create({'state':'approved', ...})). + if not self._is_graduation_manager(): + for vals in vals_list: + forbidden = [f for f in self._MANAGER_ONLY_FIELDS if vals.get(f)] + if forbidden: + raise AccessError( + _("Only graduation managers can set assessment approval fields: %s.") % ", ".join(forbidden) + ) + if vals.get("state", "draft") != "draft": + raise AccessError(_("Only graduation managers can create an assessment in a non-draft state.")) + return super().create(vals_list) + + def write(self, vals): + # Enforce the approval boundary at the ORM so it cannot be bypassed via + # RPC (the view button groups are UI-only). Non-managers may not set the + # manager-only outcome fields, and may only move state draft -> submitted. + if not self._is_graduation_manager(): + forbidden = [f for f in self._MANAGER_ONLY_FIELDS if f in vals] + if forbidden: + raise AccessError( + _("Only graduation managers can set assessment approval fields: %s.") % ", ".join(forbidden) + ) + # Content is frozen once the assessment leaves draft: the manager must + # approve exactly what was submitted (e.g. no flipping recommendation + # to "graduate" after submission). A manager can reset it to draft. + if any(f in vals for f in self._LOCKED_CONTENT_FIELDS): + for rec in self: + if rec.state != "draft": + raise AccessError( + _( + "A graduation assessment can only be edited while it is in draft. " + "Ask a manager to reset it to draft to make changes." + ) + ) + if "state" in vals: + new_state = vals["state"] + for rec in self: + if new_state != rec.state and (rec.state, new_state) != ("draft", "submitted"): + raise AccessError( + _( + "Only graduation managers can change an assessment's state; " + "you may only submit a draft for approval." + ) + ) + return super().write(vals) + class GraduationCriteriaResponse(models.Model): _name = "spp.graduation.criteria.response" diff --git a/spp_graduation/readme/HISTORY.md b/spp_graduation/readme/HISTORY.md index 79088cff3..00958f00c 100644 --- a/spp_graduation/readme/HISTORY.md +++ b/spp_graduation/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.0.2 + +- fix(security): enforce manager-only approval of graduation assessments server-side. `action_approve` / `action_reject` / `action_reset_draft` now require the graduation manager group (the view-level button gating was UI-only and bypassable via RPC), and non-managers can no longer set the approval fields (`approved_by_id` / `approved_date` / `graduation_date`), move `state` beyond `draft → submitted`, or edit a submitted assessment's content. Prevents a user from self-approving their own assessment. + ### 19.0.2.0.1 - fix(views): add a "Graduation Criteria" menu item directly under the Graduation root, plus a list/form/search view and action for `spp.graduation.criteria`. The model and ACL were already shipped, but no UI surface existed — criteria could only be edited indirectly through the pathway form. Visible to `group_spp_graduation_user` and above. diff --git a/spp_graduation/static/description/index.html b/spp_graduation/static/description/index.html index 097a6bad7..cdc9a077d 100644 --- a/spp_graduation/static/description/index.html +++ b/spp_graduation/static/description/index.html @@ -1005,6 +1005,19 @@

Changelog

+

19.0.2.0.2

+ +
+

19.0.2.0.1

-
+

19.0.2.0.0