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/__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/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 @@
+
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/__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..12d90a808
--- /dev/null
+++ b/spp_scoring_programs/tests/test_scoring_viewer_access.py
@@ -0,0 +1,76 @@
+# 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})
+
+ # 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")
+
+ # 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, [base_user_group.id, 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, [base_user_group.id, 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)