From ff231a70f8bd6b5154016fe68ab1fe871c899066 Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 24 Jul 2026 16:38:49 +0800 Subject: [PATCH 1/3] security(scoring): drop scoring-viewer read on program membership PII The auto-installed spp_scoring_programs bridge granted spp_scoring.group_scoring_viewer read on spp.program.membership. That model _inherits res.partner, so the grant exposed registrant PII (identity, contacts, IDs, bank details, relationships, other programs/entitlements, enrollment state) to any scoring viewer via direct RPC, with no program or registry role and no record rule scoping to scoring-linked programs. Remove the membership ACL row. group_scoring_viewer's remit is scoring models and results; membership access is governed by spp_programs ACLs (as the module docs already state), so a scoring+program viewer is unaffected and a pure scoring viewer no longer reaches membership PII. The spp.program read grant is kept (scoring viewers legitimately see which programs use a scoring model). Bump 19.0.2.0.1 -> 19.0.2.0.2 + HISTORY. No migration (module unreleased; ACL changes reload on update). 11 tests (3 new, run as a pure scoring viewer). --- spp_scoring_programs/__manifest__.py | 2 +- spp_scoring_programs/readme/HISTORY.md | 10 +++ .../security/ir.model.access.csv | 1 - spp_scoring_programs/tests/__init__.py | 1 + .../tests/test_scoring_viewer_access.py | 75 +++++++++++++++++++ 5 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 spp_scoring_programs/tests/test_scoring_viewer_access.py diff --git a/spp_scoring_programs/__manifest__.py b/spp_scoring_programs/__manifest__.py index 9c8d44a4f..a37822909 100644 --- a/spp_scoring_programs/__manifest__.py +++ b/spp_scoring_programs/__manifest__.py @@ -2,7 +2,7 @@ { "name": "OpenSPP Scoring Programs Bridge", "category": "OpenSPP/Targeting", - "version": "19.0.2.0.1", + "version": "19.0.2.0.2", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_scoring_programs/readme/HISTORY.md b/spp_scoring_programs/readme/HISTORY.md index 4aaf9afef..cace5af3b 100644 --- a/spp_scoring_programs/readme/HISTORY.md +++ b/spp_scoring_programs/readme/HISTORY.md @@ -1,3 +1,13 @@ +### 19.0.2.0.2 + +- fix(security): stop granting ``spp_scoring.group_scoring_viewer`` direct read + on ``spp.program.membership``. Because the membership ``_inherits`` + ``res.partner``, that grant exposed registrant PII (identity, contacts, IDs, + bank details, relationships, other programs/entitlements) to any scoring + viewer over RPC, without a program or registry role. Membership access is now + governed solely by ``spp_programs`` ACLs, as documented; scoring viewers keep + their scoring-model/result remit and read access to ``spp.program``. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_scoring_programs/security/ir.model.access.csv b/spp_scoring_programs/security/ir.model.access.csv index ffd09b64c..518750091 100644 --- a/spp_scoring_programs/security/ir.model.access.csv +++ b/spp_scoring_programs/security/ir.model.access.csv @@ -1,3 +1,2 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_spp_program_scoring_viewer,spp.program scoring viewer,spp_programs.model_spp_program,spp_scoring.group_scoring_viewer,1,0,0,0 -access_spp_program_membership_scoring_viewer,spp.program.membership scoring viewer,spp_programs.model_spp_program_membership,spp_scoring.group_scoring_viewer,1,0,0,0 diff --git a/spp_scoring_programs/tests/__init__.py b/spp_scoring_programs/tests/__init__.py index afca38bf2..3d6b0e477 100644 --- a/spp_scoring_programs/tests/__init__.py +++ b/spp_scoring_programs/tests/__init__.py @@ -1 +1,2 @@ from . import test_scoring_programs +from . import test_scoring_viewer_access diff --git a/spp_scoring_programs/tests/test_scoring_viewer_access.py b/spp_scoring_programs/tests/test_scoring_viewer_access.py new file mode 100644 index 000000000..75298122b --- /dev/null +++ b/spp_scoring_programs/tests/test_scoring_viewer_access.py @@ -0,0 +1,75 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Security: a scoring viewer must not get broad program-membership PII access. + +``spp.program.membership`` ``_inherits`` ``res.partner``, so read access to it +exposes registrant PII (names, addresses, contacts, IDs, bank details, etc.). +The bridge module must NOT grant ``spp_scoring.group_scoring_viewer`` direct +read on the membership model — membership access is governed by ``spp_programs`` +ACLs. A scoring viewer keeps its own remit (scoring models/results) and program +read; it sees memberships only if it also holds a program/registry viewer role. +""" + +from odoo.exceptions import AccessError +from odoo.tests import TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestScoringViewerMembershipAccess(TransactionCase): + """A pure scoring viewer cannot read program memberships.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Program = cls.env["spp.program"] + cls.Membership = cls.env["spp.program.membership"] + + cls.registrant = cls.env["res.partner"].create( + { + "name": "Scoring PII Test Registrant", + "given_name": "Scoring", + "family_name": "Registrant", + "is_registrant": True, + "is_group": False, + } + ) + cls.program = cls.Program.create({"name": "Scoring PII Test Program"}) + cls.membership = cls.Membership.create( + {"partner_id": cls.registrant.id, "program_id": cls.program.id} + ) + + scoring_viewer_group = cls.env.ref("spp_scoring.group_scoring_viewer") + programs_viewer_group = cls.env.ref("spp_programs.group_programs_viewer") + + # Pure scoring viewer — scoring remit only, no program/registry role. + cls.scoring_viewer = cls.env["res.users"].create( + { + "name": "Pure Scoring Viewer", + "login": "scoring_viewer_pii_test", + "group_ids": [(6, 0, [scoring_viewer_group.id])], + } + ) + # Scoring viewer who is ALSO a program viewer — membership access comes + # from spp_programs and must be unaffected. + cls.dual_viewer = cls.env["res.users"].create( + { + "name": "Scoring + Program Viewer", + "login": "scoring_program_viewer_pii_test", + "group_ids": [(6, 0, [scoring_viewer_group.id, programs_viewer_group.id])], + } + ) + + def test_pure_scoring_viewer_cannot_read_membership(self): + with self.assertRaises(AccessError): + self.Membership.with_user(self.scoring_viewer).search_read( + [("id", "=", self.membership.id)], ["partner_id"] + ) + + def test_scoring_viewer_can_still_read_program(self): + # The spp.program read grant is retained (scoring viewers see which + # programs use a scoring model). + found = self.Program.with_user(self.scoring_viewer).search([("id", "=", self.program.id)]) + self.assertIn(self.program, found) + + def test_combined_viewer_can_read_membership(self): + found = self.Membership.with_user(self.dual_viewer).search([("id", "=", self.membership.id)]) + self.assertIn(self.membership, found) From 659eef6a965982874a5634e6d3b0c2a6a0c925ea Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 24 Jul 2026 16:50:38 +0800 Subject: [PATCH 2/3] docs+style(spp_scoring_programs): regenerate README/index and ruff-format test Applies CI's pinned oca-gen output for the 19.0.2.0.2 changelog and ruff-format's formatting of the new access test. --- spp_scoring_programs/README.rst | 12 ++++++++++++ spp_scoring_programs/static/description/index.html | 13 +++++++++++++ .../tests/test_scoring_viewer_access.py | 4 +--- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/spp_scoring_programs/README.rst b/spp_scoring_programs/README.rst index 84f5e2112..e0fa4c26c 100644 --- a/spp_scoring_programs/README.rst +++ b/spp_scoring_programs/README.rst @@ -117,6 +117,18 @@ Dependencies Changelog ========= +19.0.2.0.2 +~~~~~~~~~~ + +- fix(security): stop granting ``spp_scoring.group_scoring_viewer`` + direct read on ``spp.program.membership``. Because the membership + ``_inherits`` ``res.partner``, that grant exposed registrant PII + (identity, contacts, IDs, bank details, relationships, other + programs/entitlements) to any scoring viewer over RPC, without a + program or registry role. Membership access is now governed solely by + ``spp_programs`` ACLs, as documented; scoring viewers keep their + scoring-model/result remit and read access to ``spp.program``. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_scoring_programs/static/description/index.html b/spp_scoring_programs/static/description/index.html index 3f5966aaf..4923cb852 100644 --- a/spp_scoring_programs/static/description/index.html +++ b/spp_scoring_programs/static/description/index.html @@ -480,6 +480,19 @@

Changelog

+

19.0.2.0.2

+
    +
  • fix(security): stop granting spp_scoring.group_scoring_viewer +direct read on spp.program.membership. Because the membership +_inherits res.partner, that grant exposed registrant PII +(identity, contacts, IDs, bank details, relationships, other +programs/entitlements) to any scoring viewer over RPC, without a +program or registry role. Membership access is now governed solely by +spp_programs ACLs, as documented; scoring viewers keep their +scoring-model/result remit and read access to spp.program.
  • +
+
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_scoring_programs/tests/test_scoring_viewer_access.py b/spp_scoring_programs/tests/test_scoring_viewer_access.py index 75298122b..41a901c2b 100644 --- a/spp_scoring_programs/tests/test_scoring_viewer_access.py +++ b/spp_scoring_programs/tests/test_scoring_viewer_access.py @@ -33,9 +33,7 @@ def setUpClass(cls): } ) cls.program = cls.Program.create({"name": "Scoring PII Test Program"}) - cls.membership = cls.Membership.create( - {"partner_id": cls.registrant.id, "program_id": cls.program.id} - ) + cls.membership = cls.Membership.create({"partner_id": cls.registrant.id, "program_id": cls.program.id}) scoring_viewer_group = cls.env.ref("spp_scoring.group_scoring_viewer") programs_viewer_group = cls.env.ref("spp_programs.group_programs_viewer") From a6e4e506f0da08e3f120644849f03a808146218c Mon Sep 17 00:00:00 2001 From: Edwin Gonzales Date: Fri, 24 Jul 2026 17:13:52 +0800 Subject: [PATCH 3/3] test(spp_scoring_programs): make scoring-viewer test users internal users Add base.group_user so the AccessError on the membership read is provably attributable to the missing membership ACL (and matches a realistic scoring viewer, who is an internal user). Per staff-review nit. --- spp_scoring_programs/tests/test_scoring_viewer_access.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spp_scoring_programs/tests/test_scoring_viewer_access.py b/spp_scoring_programs/tests/test_scoring_viewer_access.py index 41a901c2b..12d90a808 100644 --- a/spp_scoring_programs/tests/test_scoring_viewer_access.py +++ b/spp_scoring_programs/tests/test_scoring_viewer_access.py @@ -35,6 +35,9 @@ def setUpClass(cls): cls.program = cls.Program.create({"name": "Scoring PII Test Program"}) cls.membership = cls.Membership.create({"partner_id": cls.registrant.id, "program_id": cls.program.id}) + # Internal user baseline so the AccessError is attributable to the + # missing membership ACL, not to lacking internal-user status. + base_user_group = cls.env.ref("base.group_user") scoring_viewer_group = cls.env.ref("spp_scoring.group_scoring_viewer") programs_viewer_group = cls.env.ref("spp_programs.group_programs_viewer") @@ -43,7 +46,7 @@ def setUpClass(cls): { "name": "Pure Scoring Viewer", "login": "scoring_viewer_pii_test", - "group_ids": [(6, 0, [scoring_viewer_group.id])], + "group_ids": [(6, 0, [base_user_group.id, scoring_viewer_group.id])], } ) # Scoring viewer who is ALSO a program viewer — membership access comes @@ -52,7 +55,7 @@ def setUpClass(cls): { "name": "Scoring + Program Viewer", "login": "scoring_program_viewer_pii_test", - "group_ids": [(6, 0, [scoring_viewer_group.id, programs_viewer_group.id])], + "group_ids": [(6, 0, [base_user_group.id, scoring_viewer_group.id, programs_viewer_group.id])], } )