Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 15 additions & 5 deletions spp_cr_type_assign_program/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,21 @@ Dependencies
Changelog
=========

19.0.1.0.0 (2026-05-04)
-----------------------

Added
~~~~~
19.0.1.0.3
~~~~~~~~~~

- fix(security): validate server-side that the user selecting a program
on ``spp.cr.detail.assign_program`` can actually access it. The
``program_id`` domain only constrained the UI, so a raw RPC write
could target a hidden or cross-company program; on apply the strategy
runs under ``sudo``, which would assign the membership and leak the
program name via preview while bypassing program record rules and
multi-company scope. An ``@api.constrains`` now rejects a program the
writing user cannot see (record rules) or that is outside their
company scope.

19.0.1.0.0
~~~~~~~~~~

- New module ``spp_cr_type_assign_program`` with the ``assign_program``
change request type.
Expand Down
2 changes: 1 addition & 1 deletion spp_cr_type_assign_program/__manifest__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "OpenSPP CR Type - Assign to Program",
"version": "19.0.1.0.1",
"version": "19.0.1.0.3",
"sequence": 53,
"category": "OpenSPP",
"summary": "Change request type for assigning a registrant to a program",
Expand Down
37 changes: 36 additions & 1 deletion spp_cr_type_assign_program/details/assign_program.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from odoo import api, fields, models
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError


class SPPCRDetailAssignProgram(models.Model):
Expand Down Expand Up @@ -35,6 +36,40 @@ class SPPCRDetailAssignProgram(models.Model):
readonly=True,
)

@api.constrains("program_id")
def _check_program_access(self):
"""Reject a program the selecting user cannot access.

The `program_id` domain only constrains the UI; a raw ORM/RPC write can
point it at an arbitrary program. Since the apply strategy runs under
`sudo` (spp.change.request._do_apply), an inaccessible program would
otherwise be assigned - and its name leaked via preview - bypassing
program record rules and multi-company scope. Enforce access here, in
the writing user's own context, so the stored value can only ever be a
program that user may target.

Two checks, because neither alone is sufficient:
- `search()` requires the program to be visible to the user, enforcing
any record rule on `spp.program` (and rejecting a stale/deleted id).
- an explicit `company_id in env.companies` guard enforces multi-company
scope directly. This is load-bearing, not mere defense in depth: the
global multi-company `ir.rule` on `spp.program` is NOT reliably
applied to the search in this write/constraint context (verified by
test - a company-A user's search still returns a company-B program),
so relying on `search()` alone would let a cross-company program
through. The explicit check rejects it deterministically.
"""
for rec in self:
program = rec.program_id
if not program:
continue
# `or` short-circuits: if the record is not visible, program.company_id
# is not read (avoids an AccessError on a rule-hidden record).
if not self.env["spp.program"].search([("id", "=", program.id)]) or (
program.company_id and program.company_id not in self.env.companies
):
raise ValidationError(_("You do not have access to the selected program."))

@api.depends("registrant_id", "registrant_id.is_group")
def _compute_registrant_target_type(self):
for rec in self:
Expand Down
13 changes: 11 additions & 2 deletions spp_cr_type_assign_program/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
## 19.0.1.0.0 (2026-05-04)
### 19.0.1.0.3

### Added
- fix(security): validate server-side that the user selecting a program on
`spp.cr.detail.assign_program` can actually access it. The `program_id`
domain only constrained the UI, so a raw RPC write could target a hidden or
cross-company program; on apply the strategy runs under `sudo`, which would
assign the membership and leak the program name via preview while bypassing
program record rules and multi-company scope. An `@api.constrains` now rejects
a program the writing user cannot see (record rules) or that is outside their
company scope.

### 19.0.1.0.0

- New module `spp_cr_type_assign_program` with the `assign_program` change
request type.
Expand Down
24 changes: 16 additions & 8 deletions spp_cr_type_assign_program/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -446,21 +446,29 @@ <h1>Dependencies</h1>
<p><strong>Table of contents</strong></p>
<div class="contents local topic" id="contents">
<ul class="simple">
<li><a class="reference internal" href="#changelog" id="toc-entry-1">Changelog</a><ul>
<li><a class="reference internal" href="#section-1" id="toc-entry-2">19.0.1.0.0 (2026-05-04)</a></li>
</ul>
</li>
<li><a class="reference internal" href="#changelog" id="toc-entry-1">Changelog</a></li>
</ul>
</div>
<div class="section" id="changelog">
<h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
<div class="section" id="section-1">
<h3><a class="toc-backref" href="#toc-entry-2">19.0.1.0.0 (2026-05-04)</a></h3>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.1.0.3</h1>
<ul class="simple">
<li>fix(security): validate server-side that the user selecting a program
on <tt class="docutils literal">spp.cr.detail.assign_program</tt> can actually access it. The
<tt class="docutils literal">program_id</tt> domain only constrained the UI, so a raw RPC write
could target a hidden or cross-company program; on apply the strategy
runs under <tt class="docutils literal">sudo</tt>, which would assign the membership and leak the
program name via preview while bypassing program record rules and
multi-company scope. An <tt class="docutils literal">&#64;api.constrains</tt> now rejects a program the
writing user cannot see (record rules) or that is outside their
company scope.</li>
</ul>
</div>
<div class="section" id="added">
<h1>Added</h1>
<div class="section" id="section-2">
<h1>19.0.1.0.0</h1>
<ul class="simple">
<li>New module <tt class="docutils literal">spp_cr_type_assign_program</tt> with the <tt class="docutils literal">assign_program</tt>
change request type.</li>
Expand Down
12 changes: 11 additions & 1 deletion spp_cr_type_assign_program/strategies/assign_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ class SPPCRApplyAssignProgram(models.AbstractModel):
_description = "CR Apply: Assign to Program"

def validate(self, change_request):
"""Validate the CR can be applied. Raises UserError on any failure."""
"""Validate the CR can be applied. Raises UserError on any failure.

Note: this runs under ``sudo`` (see ``spp.change.request._do_apply``),
so it does NOT re-check the caller's access to ``program_id``. That
access is enforced when the program is selected, by
``spp.cr.detail.assign_program._check_program_access`` (a write-time
constraint in the user's own context). If a new code path ever sets
``detail.program_id`` under sudo (e.g. a prefill mapping), it must
perform its own access check - the sudo apply here trusts the stored
value.
"""
detail = change_request.get_detail()
if not detail:
raise UserError(_("No detail record found for this change request."))
Expand Down
1 change: 1 addition & 0 deletions spp_cr_type_assign_program/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import test_assign_program
from . import test_program_access
140 changes: 140 additions & 0 deletions spp_cr_type_assign_program/tests/test_program_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Security regression: the assign-program detail must reject a program the
selecting user cannot access.

The detail's ``program_id`` Many2one ``domain`` only constrains the UI; a raw
ORM/RPC write can point it at an arbitrary program. On apply the strategy runs
under ``sudo`` (`spp.change.request._do_apply`), so program record rules and the
global multi-company rule on ``spp.program`` would be bypassed — assigning a
registrant to a hidden/cross-company program and leaking its name via preview.
An ``@api.constrains`` on the detail enforces, in the writing user's own
context, that the selected program is actually visible to them.
"""

from odoo.exceptions import ValidationError
from odoo.tests import tagged

from odoo.addons.spp_change_request_v2.tests.common import CRTestCase

ASSIGN_PROGRAM_CR_TYPE_DEFS = {
"name": "Assign to Program",
"target_type": "both",
"detail_model": "spp.cr.detail.assign_program",
"apply_strategy": "custom",
"apply_model": "spp.cr.apply.assign_program",
}


@tagged("post_install", "-at_install")
class TestAssignProgramAccess(CRTestCase):
"""A CR user must not be able to target a program they cannot access."""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.Program = cls.env["spp.program"]

cls.cr_type = cls.CRType.search([("code", "=", "assign_program")], limit=1)
if not cls.cr_type:
cls.cr_type = cls.CRType.create({"code": "assign_program", **ASSIGN_PROGRAM_CR_TYPE_DEFS})

cls.company_a = cls.env.company
cls.company_b = cls.env["res.company"].create({"name": "CR Access Test Company B"})

# A program the test user CAN see (their own company).
cls.program_visible = cls.Program.create(
{
"name": "Company A Individual Program",
"target_type": "individual",
"company_id": cls.company_a.id,
}
)
# A program in another company — hidden by the global multi-company rule
# spp_programs.rule_spp_program_company for a company-A-only user.
cls.program_cross_company = cls.Program.create(
{
"name": "Company B Individual Program",
"target_type": "individual",
"company_id": cls.company_b.id,
}
)

# A CR user scoped to company A only: can write assign-program details
# (group_cr_user) and read programs (group_programs_viewer), but the
# multi-company rule keeps company-B programs out of their reach.
cls.cr_user = cls.env["res.users"].create(
{
"name": "CR User (Company A)",
"login": "cr_user_company_a",
"company_id": cls.company_a.id,
"company_ids": [(6, 0, [cls.company_a.id])],
"group_ids": [
(
4,
cls.env.ref("spp_change_request_v2.group_cr_user").id,
),
(
4,
cls.env.ref("spp_programs.group_programs_viewer").id,
),
],
}
)
# Make the cr_user's own partner a registrant so change requests can be
# created with it. Using it as the registrant models "the CR user's own
# change request" and keeps these tests robust to detail-ownership record
# rules (PR #261) that scope detail write to CRs the user owns/created.
cls.cr_user_registrant = cls.cr_user.partner_id
cls.cr_user_registrant.write({"is_registrant": True, "is_group": False})

def _cr_user_env(self, model):
"""`model` in the cr_user's env, scoped to company A only — mirroring a
real company-A session, whose allowed companies are limited to the ones
the user belongs to."""
return self.env[model].with_user(self.cr_user).with_context(allowed_company_ids=[self.company_a.id])

def _make_cr(self):
"""Create a CR as admin (CR-name sequence generation needs privileged
access) with the cr_user's own partner as registrant, so a detail write
as cr_user is allowed both today and once PR #261's ownership rule lands.
Returns the CR (admin env)."""
return self.CR.create({"request_type_id": self.cr_type.id, "registrant_id": self.cr_user_registrant.id})

def _make_cr_and_detail(self):
"""Return (cr, detail) with the detail bound to the cr_user's context —
the program_id write (what the constraint guards) then runs as cr_user."""
cr = self._make_cr()
detail = cr.get_detail()
return cr, detail.with_user(self.cr_user).with_context(allowed_company_ids=[self.company_a.id])

def test_reject_cross_company_program(self):
_cr, detail = self._make_cr_and_detail()
with self.assertRaises(ValidationError):
detail.write({"program_id": self.program_cross_company.id})

def test_reject_cross_company_program_on_create(self):
# The constraint must also fire when the program is set at create time.
# Details are created lazily, so create one directly (do not call
# get_detail first, which would auto-create the single detail).
cr = self._make_cr()
with self.assertRaises(ValidationError):
self._cr_user_env("spp.cr.detail.assign_program").create(
{
"change_request_id": cr.id,
"program_id": self.program_cross_company.id,
}
)

def test_allow_visible_program(self):
_cr, detail = self._make_cr_and_detail()
detail.write({"program_id": self.program_visible.id})
self.assertEqual(detail.program_id, self.program_visible)

def test_allow_shared_program(self):
# A company-shared program (company_id = False) is in no company's
# exclusive scope and must remain selectable.
shared = self.Program.create(
{"name": "Shared Individual Program", "target_type": "individual", "company_id": False}
)
_cr, detail = self._make_cr_and_detail()
detail.write({"program_id": shared.id})
self.assertEqual(detail.program_id, shared)
Loading