-
Notifications
You must be signed in to change notification settings - Fork 10
security(grm): evaluate rules as their owner, scope portal tickets, guard entry points (#379, #380, #381) #415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dc486df
f6861ca
341966a
1bbcdf6
4f97977
0a072d1
3b2446a
205ba28
6d8e446
1926b20
9b6a977
6b44f4c
e51dc29
3834ea5
ebee907
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
That file is the module's declared access spec (
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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
|
|
||
| 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 | ||
|
|
||
| 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, | ||
| } | ||
| ) |
| 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") |
There was a problem hiding this comment.
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_userkeeps unscopedreadonspp.grm.ticket, andsecurity/rules.xmlhas noir.ruletargetingbase.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 agroup_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 byrule_spp_grm_ticket_viewer/_officer.This PR drops the
base.group_userread 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 abase.group_userrecord rule or drop this ACL row.There was a problem hiding this comment.
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 addedbase.group_userrecord 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.