Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
dc486df
fix(spp_grm_cel): evaluate GRM rules as their owner, guard entry poin…
gonzalesedwin1123 Aug 13, 2026
f6861ca
fix(spp_grm): scope portal users to their own grievance tickets (#380)
gonzalesedwin1123 Aug 13, 2026
341966a
docs,test: regenerate GRM READMEs from CI generator, ruff-format test…
gonzalesedwin1123 Aug 13, 2026
1bbcdf6
fix(grm): address PR #415 review — atomic escalations, silent-skip lo…
gonzalesedwin1123 Sep 1, 2026
4f97977
docs(grm): regenerate READMEs from CI generator (#415)
gonzalesedwin1123 Sep 1, 2026
0a072d1
fix(grm): address PR #415 round-2 review — deferred SLA hook, fail-cl…
gonzalesedwin1123 Sep 2, 2026
3b2446a
style(grm_cel): wrap over-long log/nosemgrep lines (#415)
gonzalesedwin1123 Sep 2, 2026
205ba28
docs(grm): regenerate READMEs from CI generator (#415)
gonzalesedwin1123 Sep 2, 2026
6d8e446
fix(grm): keep unsaved form edits out of the SLA breach queue
kneckinator Sep 2, 2026
1926b20
fix(grm_cel): bound rule side effects and the manual escalation check
kneckinator Sep 2, 2026
9b6a977
docs(grm): regenerate READMEs from the updated changelog fragments
kneckinator Sep 2, 2026
6b44f4c
fix(grm_cel): fail loudly on forged rule ownership, hoist rule lookup
kneckinator Sep 2, 2026
e51dc29
docs(grm_cel): regenerate README from the updated changelog fragments
kneckinator Sep 2, 2026
3834ea5
fix(grm_cel): refuse cases with no real worker, keep parser defects l…
kneckinator Sep 2, 2026
ebee907
docs(grm_cel): regenerate README from the updated changelog fragments
kneckinator Sep 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions spp_grm/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,27 @@ Dependencies
Changelog
=========

19.0.2.0.2
~~~~~~~~~~

- fix(security): portal users can now only access their OWN grievance
tickets. The ``spp.grm.ticket`` portal access was read/write/create
with no record rule, so any portal user could read and rewrite every
grievance in the system over RPC (#380). Added a portal record rule
scoping to the user's own partner and reduced the portal
access-control entry to read-only (submission is handled by the sudo'd
portal controller, which needs no direct model write). The rule covers
all four operations, so the scoping also holds if a future
access-control change ever re-grants portal write.
- fix: SLA-breach handling (auto-escalation and the breach chatter note)
no longer runs inside the stored ``sla_status`` compute. It is
deferred to the end of the triggering transaction, so the escalation
engine's writes, savepoints and flushes never execute mid-computation.
Same transaction, same outcome. An unsaved form edit queues nothing:
the compute also runs on the pseudo-record of an onchange, whose ids
resolve back to the real ticket, which would have escalated it for a
change the user never saved.

19.0.2.0.1
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_grm/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"name": "OpenSPP - Grievance Redress Mechanism",
"summary": "Provides a centralized Grievance Redress Mechanism for receiving, tracking, and resolving beneficiary complaints and feedback. It supports multi-channel submission, manages resolution workflows through customizable stages, and links grievances directly to individual or group registrants.",
"version": "19.0.2.0.1",
"version": "19.0.2.0.2",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
37 changes: 33 additions & 4 deletions spp_grm/models/grm_ticket.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,39 @@ def _compute_sla_status(self):
# Trigger escalation when status transitions to 'breached'
# Only trigger if the status actually changed to breached (not already breached)
if ticket.sla_status == "breached" and old_status != "breached":
# Use sudo() to call _on_sla_breach in a new environment context
# to avoid triggering compute dependencies during the compute itself
# nosemgrep: semgrep.odoo-sudo-without-context
ticket.sudo()._on_sla_breach()
ticket._schedule_sla_breach()

def _schedule_sla_breach(self):
"""Queue ``_on_sla_breach`` for these tickets once the compute is over.

The breach hook drives the escalation engine: ticket writes, chatter
posts and savepoints that flush and may roll back. None of that may run
inside a stored compute, so it is deferred to the transaction's
precommit stage and runs at the next full flush/commit, still in the
same transaction (the way mail.thread defers its tracking messages).

Pseudo-records are dropped first. A stored compute also runs on the
unsaved record of a form onchange, and ``ids`` resolves those to their
origin, so queueing them would run the breach hook — escalation engine
included — against the real ticket for an edit that was never saved.
A NewId is falsy, so ``ticket.id`` tells the two apart.
"""
tickets = self.filtered(lambda ticket: ticket.id)
if not tickets:
return
pending = self.env.cr.precommit.data.setdefault("spp_grm.sla_breach_ids", set())
pending.update(tickets.ids)
self.env.cr.precommit.add(tickets._run_sla_breach_hooks)

def _run_sla_breach_hooks(self):
"""Precommit callback: run the breach hook for every ticket queued so far."""
pending = self.env.cr.precommit.data.pop("spp_grm.sla_breach_ids", set())
if not pending:
return
# Breach handling runs elevated, as it always did; every escalation
# rule effect is bounded by the rule owner's identity, not by this env.
# nosemgrep: semgrep.odoo-sudo-without-context
self.browse(sorted(pending)).exists().sudo()._on_sla_breach()

def _on_sla_breach(self):
"""Called when ticket SLA status changes to breached.
Expand Down
16 changes: 16 additions & 0 deletions spp_grm/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
### 19.0.2.0.2

- fix(security): portal users can now only access their OWN grievance tickets. The
``spp.grm.ticket`` portal access was read/write/create with no record rule, so any portal user
could read and rewrite every grievance in the system over RPC (#380). Added a portal record rule
scoping to the user's own partner and reduced the portal access-control entry to read-only
(submission is handled by the sudo'd portal controller, which needs no direct model write). The
rule covers all four operations, so the scoping also holds if a future access-control change
ever re-grants portal write.
- fix: SLA-breach handling (auto-escalation and the breach chatter note) no longer runs inside
the stored ``sla_status`` compute. It is deferred to the end of the triggering transaction, so
the escalation engine's writes, savepoints and flushes never execute mid-computation. Same
transaction, same outcome. An unsaved form edit queues nothing: the compute also runs on the
pseudo-record of an onchange, whose ids resolve back to the real ticket, which would have
escalated it for a change the user never saved.

### 19.0.2.0.1

- fix(views): gate the "Helpdesk" top-level menu (`spp_grm_ticket_main_menu`) on `group_grm_viewer`. Previously the root menu had no `groups=` attribute and was visible to every logged-in user; the OP#951 menu audit requires several roles to NOT see it (Registry Viewer, Global Finance, Global Program Manager, Program Viewer/Validator/Cycle Approver, Global Registrar, CR roles, Farm User/Manager).
Expand Down
25 changes: 24 additions & 1 deletion spp_grm/security/compliance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,28 @@ record_rules:
perm_create: true
perm_unlink: false

# Officer - unscoped create only (new tickets have no assignment yet)
- id: rule_spp_grm_ticket_officer_create
model: spp.grm.ticket
groups: [group_grm_officer]
domain_description: "Officer can create tickets regardless of assignment"
perm_read: false
perm_write: false
perm_create: true
perm_unlink: false

# Portal - own tickets only (#380); all perms so scoping holds if a
# future ACL change ever re-grants portal write/create/unlink
- id: rule_spp_grm_ticket_portal
model: spp.grm.ticket
groups: [base.group_portal]
domain_description:
"Portal user can only access their own tickets (partner_id = user.partner_id)"
perm_read: true
perm_write: true
perm_create: true
perm_unlink: true

# Supervisor - team tickets they supervise
- id: rule_spp_grm_ticket_supervisor
model: spp.grm.ticket
Expand Down Expand Up @@ -287,7 +309,8 @@ actions: []
#
# Additional Access:
# - base.group_user: Read-only access to tickets (for internal users)
# - base.group_portal: Create/edit own tickets (for portal/self-service)
# - base.group_portal: Read own tickets only (submission goes through the
# sudo'd portal controller; no direct model write/create — see #380)
#
# Record Rules:
# - Viewer: user_id = current user
Expand Down
2 changes: 1 addition & 1 deletion spp_grm/security/ir.model.access.csv
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ access_spp_grm_ticket_viewer,GRM Ticket Viewer Access,model_spp_grm_ticket,group
access_spp_grm_ticket_officer,GRM Ticket Officer Access,model_spp_grm_ticket,group_grm_officer,1,1,1,0
access_spp_grm_ticket_manager,GRM Ticket Manager Access,model_spp_grm_ticket,group_grm_manager,1,1,1,1
access_spp_grm_ticket_base_user,GRM Ticket Base User Access,model_spp_grm_ticket,base.group_user,1,0,0,0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same enumeration hole #380 fixes for portal is still wide open for every internal user.

base.group_user keeps unscoped read on spp.grm.ticket, and security/rules.xml has no ir.rule targeting base.group_user. Because Odoo ORs the record rules of the groups a user belongs to, a plain internal user (Registry Viewer, Farm User, Program Viewer — none of them in a group_grm_* group) matches no rule on this model and therefore reads every grievance in the database: complainant identity, description, contact. Only users who do hold a GRM group get scoped down by rule_spp_grm_ticket_viewer / _officer.

This PR drops the base.group_user read rows on both rule models for precisely this reason ("only exposed the routing/escalation map to enumeration"), so leaving the far more sensitive ticket model unscoped is inconsistent. Either add a base.group_user record rule or drop this ACL row.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, including the core semantics (no matching group rule and no global rule ⇒ unrestricted), and agreed it's the same class of hole as #380 — thank you. Two wrinkles argue for doing it as an immediate follow-up rather than in this PR: dropping the ACL row would break res_partner._compute_grm_ticket_count (an unsudo'd search that runs for every internal user opening a partner form), so the right shape is an added base.group_user record rule, not a removal; and scoping every internal user's GRM visibility is a behavior change for non-GRM staff that deserves its own release-note headline and its own review rather than riding along unannounced here. Filed as #486 with the proposed rule domain; happy to have it land right behind this PR.

access_spp_grm_ticket_portal_user,GRM Ticket Portal User Access,model_spp_grm_ticket,base.group_portal,1,1,1,0
access_spp_grm_ticket_portal_user,GRM Ticket Portal User Access,model_spp_grm_ticket,base.group_portal,1,0,0,0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spp_grm/security/compliance.yaml now contradicts the code it declares.

That file is the module's declared access spec (python -m scripts.compliance.checker spp_grm, wired into .pre-commit-config.yaml:213). Two statements in it are now false and were not updated:

  • line 290: # - base.group_portal: Create/edit own tickets (for portal/self-service) — portal is read-only as of this row.
  • record_rules: does not declare the new rule_spp_grm_ticket_portal.

I ran the checker against this branch: 0 errors, 0 warnings — it validates declared entries against reality but does not detect entries that exist in code and are missing from the spec. So this drift is silent, and the next reader of compliance.yaml will get the pre-#380 picture. (rule_spp_grm_ticket_officer_create is already undeclared for the same reason — worth fixing both while here.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed: the portal comment now describes read-only access via the sudo'd controller, and both rule_spp_grm_ticket_portal and the pre-existing undeclared rule_spp_grm_ticket_officer_create are declared under record_rules. Good catch on the checker's blind direction.


access_spp_grm_ticket_stage_viewer,GRM Ticket Stage Viewer Access,model_spp_grm_ticket_stage,group_grm_viewer,1,0,0,0
access_spp_grm_ticket_stage_officer,GRM Ticket Stage Officer Access,model_spp_grm_ticket_stage,group_grm_officer,1,0,0,0
Expand Down
18 changes: 18 additions & 0 deletions spp_grm/security/rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,22 @@
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[Command.link(ref('group_grm_manager'))]" />
</record>

<!-- Portal users may only see their OWN grievance tickets. Without this rule
the portal ACL row (read-only) is unscoped and every portal user could
read every grievance in the system (#380). Portal submission is mediated
by the sudo'd controller, so the ACL grants no direct write/create; all
four perms are enabled here so the scoping also holds for write/create/
unlink if any future ACL change re-grants those (a rule with a perm
disabled simply does not apply to that operation). -->
<record id="rule_spp_grm_ticket_portal" model="ir.rule">
<field name="name">GRM Ticket: Portal Own Tickets Only</field>
<field ref="model_spp_grm_ticket" name="model_id" />
<field name="domain_force">[('partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[Command.link(ref('base.group_portal'))]" />
<field name="perm_read" eval="True" />
<field name="perm_write" eval="True" />
<field name="perm_create" eval="True" />
<field name="perm_unlink" eval="True" />
</record>
</odoo>
24 changes: 23 additions & 1 deletion spp_grm/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,28 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.0.2</h1>
<ul class="simple">
<li>fix(security): portal users can now only access their OWN grievance
tickets. The <tt class="docutils literal">spp.grm.ticket</tt> portal access was read/write/create
with no record rule, so any portal user could read and rewrite every
grievance in the system over RPC (#380). Added a portal record rule
scoping to the user’s own partner and reduced the portal
access-control entry to read-only (submission is handled by the sudo’d
portal controller, which needs no direct model write). The rule covers
all four operations, so the scoping also holds if a future
access-control change ever re-grants portal write.</li>
<li>fix: SLA-breach handling (auto-escalation and the breach chatter note)
no longer runs inside the stored <tt class="docutils literal">sla_status</tt> compute. It is
deferred to the end of the triggering transaction, so the escalation
engine’s writes, savepoints and flushes never execute mid-computation.
Same transaction, same outcome. An unsaved form edit queues nothing:
the compute also runs on the pseudo-record of an onchange, whose ids
resolve back to the real ticket, which would have escalated it for a
change the user never saved.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.0.1</h1>
<ul class="simple">
<li>fix(views): gate the “Helpdesk” top-level menu
Expand All @@ -547,7 +569,7 @@ <h1>19.0.2.0.1</h1>
Farm User/Manager).</li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
3 changes: 3 additions & 0 deletions spp_grm/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
from . import test_grm_ticket_stage
from . import test_res_partner
from . import test_grm_security
from . import test_portal_ticket_acl
from . import test_portal_ticket_submit
from . import test_sla_breach
87 changes: 87 additions & 0 deletions spp_grm/tests/test_portal_ticket_acl.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Security: portal users must only reach their OWN grievance tickets.

Regression test for #380: spp.grm.ticket granted base.group_portal read/write/create
with NO ir.rule targeting portal, so a portal user could read and rewrite every
grievance in the system over RPC. The controller's partner_id scoping is
presentation-only. Fix: a portal record rule scoping to the user's own partner, and
the portal ACL row reduced to read-only (portal submission runs through the sudo'd
controller, which needs no direct model write/create).
"""

from odoo import Command
from odoo.exceptions import AccessError
from odoo.tests import TransactionCase, tagged


@tagged("post_install", "-at_install")
class TestPortalTicketAcl(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Users = cls.env["res.users"]
cls.portal_a = Users.create(
{
"name": "Portal A",
"login": "grm_portal_a",
"group_ids": [Command.link(cls.env.ref("base.group_portal").id)],
}
)
cls.portal_b = Users.create(
{
"name": "Portal B",
"login": "grm_portal_b",
"group_ids": [Command.link(cls.env.ref("base.group_portal").id)],
}
)
Ticket = cls.env["spp.grm.ticket"]
cls.ticket_a = Ticket.create(
{
"name": "A's grievance",
"description": "Private to A",
"partner_id": cls.portal_a.partner_id.id,
}
)
cls.ticket_b = Ticket.create(
{
"name": "B's grievance",
"description": "Private to B",
"partner_id": cls.portal_b.partner_id.id,
}
)

def test_portal_can_read_own_ticket(self):
"""A portal user reads their own grievance (controller-created)."""
own = self.ticket_a.with_user(self.portal_a)
self.assertEqual(own.name, "A's grievance")

def test_portal_cannot_read_others_ticket(self):
"""A portal user must NOT be able to read another user's grievance."""
with self.assertRaises(AccessError):
self.ticket_b.with_user(self.portal_a).read(["name"])

def test_portal_cannot_search_others_ticket(self):
"""search must not surface other users' grievances to a portal user."""
visible = self.env["spp.grm.ticket"].with_user(self.portal_a).search([])
self.assertIn(self.ticket_a, visible)
self.assertNotIn(self.ticket_b, visible)

def test_portal_cannot_write_any_ticket(self):
"""Portal ACL is read-only: no write on own or others' tickets over RPC
(edits go through the controller, not direct model writes)."""
with self.assertRaises(AccessError):
self.ticket_a.with_user(self.portal_a).write({"name": "tampered"})
with self.assertRaises(AccessError):
self.ticket_b.with_user(self.portal_a).write({"name": "hijacked"})

def test_portal_cannot_create_ticket_directly(self):
"""Portal ACL is read-only: direct model create is denied (submission is
controller-mediated via sudo)."""
with self.assertRaises(AccessError):
self.env["spp.grm.ticket"].with_user(self.portal_a).create(
{
"name": "direct",
"description": "bypass controller",
"partner_id": self.portal_a.partner_id.id,
}
)
55 changes: 55 additions & 0 deletions spp_grm/tests/test_portal_ticket_submit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Part of OpenSPP. See LICENSE file for full copyright and licensing details.
"""Portal grievance submission still works end to end with a read-only portal ACL.

The portal access-control entry on ``spp.grm.ticket`` grants read only (#380);
submission goes through the sudo'd ``/my/ticket/submit`` controller. This pins
that route: the form page loads for a portal user (non-sudo category/channel
lookups), the POST creates the ticket for the submitter's partner on the web
channel, and the submitter can read it back.
"""

import re

from odoo import Command
from odoo.tests import HttpCase, tagged


@tagged("post_install", "-at_install")
class TestPortalTicketSubmit(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.portal = cls.env["res.users"].create(
{
"name": "Portal Submitter",
"login": "grm_portal_submit",
"password": "grm_portal_submit_pw",
"group_ids": [Command.link(cls.env.ref("base.group_portal").id)],
}
)
cls.category = cls.env["spp.grm.ticket.category"].create({"name": "Portal Cat"})

def test_portal_submit_route_creates_own_ticket(self):
self.authenticate("grm_portal_submit", "grm_portal_submit_pw")
page = self.url_open("/my/ticket/new")
self.assertEqual(page.status_code, 200, page.text[:500])
match = re.search(r'name="csrf_token"\s+value="([^"]+)"', page.text)
self.assertTrue(match, "csrf token not found in /my/ticket/new form")
resp = self.url_open(
"/my/ticket/submit",
data={
"csrf_token": match.group(1),
"ticket_name": "Portal grievance",
"description": "Submitted through the portal",
"category_id": str(self.category.id),
},
)
self.assertEqual(resp.status_code, 200, resp.text[:500])
self.assertTrue(resp.url.endswith("/my/tickets"), resp.url)
ticket = self.env["spp.grm.ticket"].search([("name", "=", "Portal grievance")])
self.assertEqual(len(ticket), 1)
self.assertEqual(ticket.partner_id, self.portal.partner_id)
self.assertEqual(ticket.channel_id, self.env.ref("spp_grm.grm_ticket_channel_web"))
self.assertEqual(ticket.category_id, self.category)
# The submitter can read their own ticket back over the model layer.
self.assertEqual(ticket.with_user(self.portal).name, "Portal grievance")
Loading
Loading