-
Notifications
You must be signed in to change notification settings - Fork 4
fix(spp_programs): close membership ACL bypass and approval/payment bugs #241
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
Open
kneckinator
wants to merge
1
commit into
19.0
Choose a base branch
from
fix/security-scan-findings
base: 19.0
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -413,6 +413,13 @@ def bulk_create_memberships(self, vals_list, chunk_size=1000, skip_duplicates=Fa | |
| raising IntegrityError. Returns the count of inserted rows. | ||
| :return: Recordset (skip_duplicates=False) or int count (skip_duplicates=True) | ||
| """ | ||
| # This is a public (RPC-callable) method. The skip_duplicates path uses | ||
| # raw SQL and the ORM path runs through sudo(), both of which bypass the | ||
| # ORM's ACL checks. Enforce model-level create access explicitly so a | ||
| # low-privileged user cannot use it to create memberships they could not | ||
| # create through the ORM. | ||
| self.check_access("create") | ||
|
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. |
||
|
|
||
| if not vals_list: | ||
| return 0 if skip_duplicates else self.env["spp.program.membership"] | ||
|
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
spp_programs/tests/test_approve_entitlements_program_isolation.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| # Part of OpenSPP. See LICENSE file for full copyright and licensing details. | ||
| """Security scan finding: "Fund balance cached from first entitlement program". | ||
|
|
||
| ``approve_entitlements`` fetches the fund balance once with | ||
| ``check_fund_balance(entitlements[0].cycle_id.program_id.id)`` and reuses it | ||
| for every record in the batch. Two consequences: | ||
|
|
||
| 1. If a mixed-program recordset is passed, entitlements belonging to a later | ||
| program are approved against the *first* program's balance instead of their | ||
| own — bypassing the later program's fund limit. | ||
| 2. ``entitlements[0]`` raises ``IndexError`` on an empty recordset instead of | ||
| returning cleanly. | ||
|
|
||
| The previous implementation called ``check_fund_balance(rec.cycle_id.program_id.id)`` | ||
| inside the loop, so each entitlement was evaluated against its own program. | ||
| """ | ||
|
|
||
| import uuid | ||
| from unittest.mock import patch | ||
|
|
||
| from odoo import fields | ||
| from odoo.tests import TransactionCase, tagged | ||
|
|
||
|
|
||
| @tagged("post_install", "-at_install") | ||
| class TestApproveEntitlementsProgramIsolation(TransactionCase): | ||
| def _make_program_with_cycle(self): | ||
| program = self.env["spp.program"].create({"name": f"Program {uuid.uuid4().hex[:8]}"}) | ||
| journal = self.env["account.journal"].create( | ||
| {"name": "J", "type": "bank", "code": f"J{uuid.uuid4().hex[:4].upper()}"} | ||
| ) | ||
| program.journal_id = journal.id | ||
| cycle = self.env["spp.cycle"].create( | ||
| { | ||
| "name": "Cycle", | ||
| "program_id": program.id, | ||
| "start_date": fields.Date.today(), | ||
| "end_date": fields.Date.today(), | ||
| } | ||
| ) | ||
| manager = self.env["spp.program.entitlement.manager.default"].create( | ||
| {"name": "Mgr", "program_id": program.id, "amount_per_cycle": 100.0} | ||
| ) | ||
| return program, cycle, manager | ||
|
|
||
| def _make_entitlement(self, cycle, partner): | ||
| return self.env["spp.entitlement"].create( | ||
| { | ||
| "partner_id": partner.id, | ||
| "cycle_id": cycle.id, | ||
| "initial_amount": 100.0, | ||
| "state": "pending_validation", | ||
| "is_cash_entitlement": True, | ||
| } | ||
| ) | ||
|
|
||
| def test_empty_recordset_does_not_crash(self): | ||
| """approve_entitlements on an empty recordset must not raise IndexError.""" | ||
| _program, _cycle, manager = self._make_program_with_cycle() | ||
| empty = self.env["spp.entitlement"] | ||
| # On the buggy code entitlements[0] raises IndexError before the loop. | ||
| manager.approve_entitlements(empty) | ||
|
|
||
| def test_each_entitlement_checked_against_its_own_program(self): | ||
| """A mixed-program batch must evaluate each entitlement's own program fund. | ||
|
|
||
| We record every program_id passed to check_fund_balance. With the | ||
| single-fetch bug only the first program is ever queried, so the second | ||
| program's id is missing from the recorded set. | ||
| """ | ||
| _p1, cycle1, manager = self._make_program_with_cycle() | ||
| p2, cycle2, _manager2 = self._make_program_with_cycle() | ||
| partner_a = self.env["res.partner"].create({"name": "A", "is_registrant": True}) | ||
| partner_b = self.env["res.partner"].create({"name": "B", "is_registrant": True}) | ||
|
|
||
| ent1 = self._make_entitlement(cycle1, partner_a) | ||
| ent2 = self._make_entitlement(cycle2, partner_b) | ||
| mixed = ent1 | ent2 | ||
|
|
||
| checked_program_ids = [] | ||
|
|
||
| def _record(self_mgr, program_id): | ||
| checked_program_ids.append(program_id) | ||
| return 10000.0 # plenty of funds so the loop runs to completion | ||
|
|
||
| with patch.object(type(manager), "check_fund_balance", _record): | ||
| manager.approve_entitlements(mixed) | ||
|
|
||
| self.assertIn( | ||
| p2.id, | ||
| checked_program_ids, | ||
| "second program's fund balance was never checked — its entitlement was " | ||
| "approved against the first program's funds", | ||
| ) |
84 changes: 84 additions & 0 deletions
84
spp_programs/tests/test_cycle_null_entitlement_approval.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| # Part of OpenSPP. See LICENSE file for full copyright and licensing details. | ||
| """Security scan finding: "NULL entitlement states can pass approval check". | ||
|
|
||
| ``_compute_all_entitlements_approved`` finds unapproved cycles with the SQL | ||
| predicate ``state != 'approved'``. In SQL ``NULL != 'approved'`` evaluates to | ||
| UNKNOWN (not TRUE), so an entitlement whose ``state`` is NULL is excluded from | ||
| ``cycles_with_unapproved``. A cycle containing only approved entitlements plus | ||
| one NULL-state entitlement therefore computes ``all_entitlements_approved = | ||
| True`` — re-introducing the unapproved entitlement into a "fully approved" | ||
| cycle. The previous ``all(ent.state == "approved" ...)`` logic treated | ||
| NULL/False as "not approved" and was safe. | ||
|
|
||
| The entitlement ``state`` field has a default of "draft" but is not required, | ||
| so NULL rows are reachable via raw SQL / imports / RPC writes. | ||
| """ | ||
|
|
||
| import uuid | ||
|
|
||
| from odoo import fields | ||
| from odoo.tests import TransactionCase, tagged | ||
|
|
||
|
|
||
| @tagged("post_install", "-at_install") | ||
| class TestCycleNullEntitlementApproval(TransactionCase): | ||
| def setUp(self): | ||
| super().setUp() | ||
| self.program = self.env["spp.program"].create({"name": f"Test Program {uuid.uuid4().hex[:8]}"}) | ||
| self.journal = self.env["account.journal"].create( | ||
| {"name": "Test Journal", "type": "bank", "code": f"TJ{uuid.uuid4().hex[:4].upper()}"} | ||
| ) | ||
| self.program.journal_id = self.journal.id | ||
| self.cycle = self.env["spp.cycle"].create( | ||
| { | ||
| "name": "Test Cycle", | ||
| "program_id": self.program.id, | ||
| "start_date": fields.Date.today(), | ||
| "end_date": fields.Date.today(), | ||
| } | ||
| ) | ||
| self.partners = self.env["res.partner"].create( | ||
| [{"name": f"Registrant {i}", "is_registrant": True} for i in range(2)] | ||
| ) | ||
|
|
||
| def _make_entitlement(self, partner, state): | ||
| return self.env["spp.entitlement"].create( | ||
| { | ||
| "partner_id": partner.id, | ||
| "cycle_id": self.cycle.id, | ||
| "initial_amount": 100.0, | ||
| "state": state, | ||
| "is_cash_entitlement": True, | ||
| } | ||
| ) | ||
|
|
||
| def test_null_state_entitlement_is_not_treated_as_approved(self): | ||
| """A cycle with an approved + a NULL-state entitlement is NOT fully approved.""" | ||
| approved = self._make_entitlement(self.partners[0], "approved") | ||
| other = self._make_entitlement(self.partners[1], "draft") | ||
|
|
||
| # Force the second entitlement's state to NULL, the way a raw import / | ||
| # RPC write that omits the default could. (ORM create would apply the | ||
| # 'draft' default, so we go through SQL to reproduce the NULL row.) | ||
| self.env.cr.execute("UPDATE spp_entitlement SET state = NULL WHERE id = %s", (other.id,)) | ||
| self.cycle.invalidate_recordset(["all_entitlements_approved"]) | ||
|
|
||
| # Sanity: there really is a NULL-state entitlement in this cycle. | ||
| self.env.cr.execute( | ||
| "SELECT COUNT(*) FROM spp_entitlement WHERE cycle_id = %s AND state IS NULL", | ||
| (self.cycle.id,), | ||
| ) | ||
| self.assertEqual(self.env.cr.fetchone()[0], 1, "precondition: one NULL-state entitlement must exist") | ||
| self.assertEqual(approved.state, "approved") | ||
|
|
||
| self.assertFalse( | ||
| self.cycle.all_entitlements_approved, | ||
| "Cycle has a NULL-state (unapproved) entitlement; all_entitlements_approved must be False", | ||
| ) | ||
|
|
||
| def test_all_approved_is_true_when_truly_all_approved(self): | ||
| """Control: when every entitlement is approved, the flag is True.""" | ||
| self._make_entitlement(self.partners[0], "approved") | ||
| self._make_entitlement(self.partners[1], "approved") | ||
| self.cycle.invalidate_recordset(["all_entitlements_approved"]) | ||
| self.assertTrue(self.cycle.all_entitlements_approved) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
In Odoo, there is no standard
check_accessmethod on models. The standard and correct method to check model-level access rights ischeck_access_rights. Usingcheck_accesswill raise anAttributeErrorat runtime.