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
9 changes: 9 additions & 0 deletions spp_farmer_registry_demo/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ Dependencies
Changelog
=========

19.0.2.1.2
~~~~~~~~~~

- fix(demo): release/force the cycle operation lock through the
``_release_operation_lock`` helper instead of writing ``is_locked``
directly, so demo generation stays compatible with the
``spp_programs`` 19.0.2.2.1 guard that restricts direct writes to the
lock fields to system admins.

19.0.2.1.1
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_farmer_registry_demo/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "OpenSPP Farmer Registry Demo",
"summary": "Demo generator for Farmer Registry with fixed stories and volume generation",
"category": "OpenSPP",
"version": "19.0.2.1.1",
"version": "19.0.2.1.2",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
5 changes: 3 additions & 2 deletions spp_farmer_registry_demo/models/farmer_demo_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2297,7 +2297,7 @@ def _create_single_cycle(self, program):
try:
program_beneficiaries = program.get_beneficiaries("enrolled").mapped("partner_id.id")
cycle_manager._add_beneficiaries(cycle, program_beneficiaries, "enrolled", do_count=True)
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
_logger.info(
"Synced beneficiary import for cycle (cycle_id=%s, count=%s)",
cycle.id,
Expand Down Expand Up @@ -2343,7 +2343,8 @@ def _create_single_cycle(self, program):
exc,
)
if cycle.state == "draft":
cycle.write({"state": "to_approve", "is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
cycle.write({"state": "to_approve"})

# Step 4: Approve cycle (to_approve -> approved)
try:
Expand Down
7 changes: 7 additions & 0 deletions spp_farmer_registry_demo/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
### 19.0.2.1.2

- fix(demo): release/force the cycle operation lock through the
`_release_operation_lock` helper instead of writing `is_locked` directly,
so demo generation stays compatible with the `spp_programs` 19.0.2.2.1
guard that restricts direct writes to the lock fields to system admins.

### 19.0.2.1.1

- fix(demo): name each farm after its head member and give every member the head's family name so a household reads as one family; farm names and registry IDs stay unique and generation remains seed-deterministic, resolving duplicate farm names and duplicate Tax/National IDs (#1114)
Expand Down
14 changes: 12 additions & 2 deletions spp_farmer_registry_demo/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,16 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.1.2</h1>
<ul class="simple">
<li>fix(demo): release/force the cycle operation lock through the
<tt class="docutils literal">_release_operation_lock</tt> helper instead of writing <tt class="docutils literal">is_locked</tt>
directly, so demo generation stays compatible with the
<tt class="docutils literal">spp_programs</tt> 19.0.2.2.1 guard that restricts direct writes to the
lock fields to system admins.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.1.1</h1>
<ul class="simple">
<li>fix(demo): name each farm after its head member and give every member
Expand All @@ -502,7 +512,7 @@ <h1>19.0.2.1.1</h1>
(#1114)</li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.2.1.0</h1>
<ul class="simple">
<li>feat(demo): add GIS + irrigation scenario (FM4) with reservoir + canal
Expand All @@ -521,7 +531,7 @@ <h1>19.0.2.1.0</h1>
tables and the CR overview</li>
</ul>
</div>
<div class="section" id="section-3">
<div class="section" id="section-4">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
12 changes: 12 additions & 0 deletions spp_program_geofence/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ Known Limitations
Changelog
=========

19.0.1.0.1
----------

- fix(security): route the async import lock through the operation-lock
helpers so it keeps working under the new ``spp.program`` write guard.
``spp_programs`` 19.0.2.2.1 restricts direct writes to ``is_locked`` /
``locked_reason`` to system administrators; the geofence import
acquired and released the lock with plain writes as the initiating
(non-admin) user, which the guard would reject — leaving the program
stuck locked. It now uses ``_acquire_operation_lock`` /
``_release_operation_lock`` (which ``sudo()``).

19.0.1.0.0
----------

Expand Down
2 changes: 1 addition & 1 deletion spp_program_geofence/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "OpenSPP Program Geofence",
"summary": "Geofence-based geographic targeting for programs using spatial queries.",
"category": "OpenSPP",
"version": "19.0.1.0.0",
"version": "19.0.1.0.1",
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
"license": "LGPL-3",
Expand Down
5 changes: 2 additions & 3 deletions spp_program_geofence/models/eligibility_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ def _import_registrants_async(self, new_beneficiaries, state="draft"):
self.ensure_one()
program = self.program_id
program.message_post(body=_("Import of %s beneficiaries started.") % len(new_beneficiaries))
program.write({"is_locked": True, "locked_reason": _("Importing beneficiaries")})
program._acquire_operation_lock(_("Importing beneficiaries"))

jobs = []
for i in range(0, len(new_beneficiaries), self.IMPORT_CHUNK_SIZE):
Expand All @@ -218,8 +218,7 @@ def mark_import_as_done(self):
self.ensure_one()
self.program_id._compute_eligible_beneficiary_count()
self.program_id._compute_beneficiary_count()
self.program_id.is_locked = False
self.program_id.locked_reason = None
self.program_id._release_operation_lock()
self.program_id.message_post(body=_("Import finished."))

def _import_registrants(self, new_beneficiaries, state="draft", do_count=False):
Expand Down
10 changes: 10 additions & 0 deletions spp_program_geofence/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 19.0.1.0.1

- fix(security): route the async import lock through the operation-lock
helpers so it keeps working under the new `spp.program` write guard.
`spp_programs` 19.0.2.2.1 restricts direct writes to `is_locked` /
`locked_reason` to system administrators; the geofence import acquired and
released the lock with plain writes as the initiating (non-admin) user,
which the guard would reject — leaving the program stuck locked. It now
uses `_acquire_operation_lock` / `_release_operation_lock` (which `sudo()`).

## 19.0.1.0.0

- Initial release: geofence-based program targeting and eligibility management (Tier 1 coordinate intersection, Tier 2 area-intersection fallback), program configuration UI, and program creation wizard support.
16 changes: 16 additions & 0 deletions spp_programs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,22 @@ Dependencies
Changelog
=========

19.0.2.2.1
~~~~~~~~~~

- fix(security): make the async operation lock a server-side boundary.
The Force Unlock buttons were gated to ``base.group_system`` in the
views, but ``action_force_unlock`` on ``spp.cycle`` / ``spp.program``
— and direct writes to the ``is_locked`` / ``locked_reason`` fields —
had no server-side check, so any role holding write access (program
officers, managers, cycle approvers) could clear an active operation
lock via RPC while async entitlement / payment / eligibility jobs were
still running. Direct writes to the lock fields now require
``base.group_system`` (via a ``write()`` guard), the manual
``action_force_unlock`` override requires the same, and the async
pipeline manages the lock through ``sudo()`` helpers so legitimate
acquire/release from the initiating user keeps working.

19.0.2.1.3
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_programs/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "OpenSPP Programs",
"summary": "Manage programs, cycles, beneficiary enrollment, entitlements (cash and in-kind), payments, and fund tracking for social protection.",
"category": "OpenSPP/Core",
"version": "19.0.2.2.0",
"version": "19.0.2.2.1",
"sequence": 1,
"author": "OpenSPP.org",
"website": "https://github.com/OpenSPP/OpenSPP2",
Expand Down
55 changes: 54 additions & 1 deletion spp_programs/models/cycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -1083,14 +1083,67 @@ def _get_related_job_domain(self):
related_jobs = jobs.filtered(lambda r: self in r.args[0])
return [("id", "in", related_jobs.ids)]

def write(self, vals):
# ``is_locked`` / ``locked_reason`` form an operation lock protecting
# in-flight async pipelines (entitlement, payment, eligibility).
# Clearing or setting it out of band lets conflicting operations run,
# so direct writes to these fields are restricted to system
# administrators. The pipeline manages the lock through
# ``_acquire_operation_lock`` / ``_release_operation_lock`` (which
# ``sudo()``), and Force Unlock is the admin-only manual override.
if not self.env.su and ("is_locked" in vals or "locked_reason" in vals):
if not self.env.user.has_group("base.group_system"):
raise AccessError(
_(
"Changing the operation lock is restricted to system "
"administrators. The lock is managed automatically by "
"the async pipeline; use Force Unlock only in an emergency."
)
)
return super().write(vals)

# NOTE(#337): these helpers sudo the lock write, so any PUBLIC method that
# calls them (e.g. the async mark_*_as_done / mark_*_as_failed completion
# callbacks) is an RPC-reachable lock-clearing path that the write() guard
# above does not cover. Closing that needs authorization on those
# callbacks and is tracked separately in issue #337.
def _acquire_operation_lock(self, reason):
"""Set the async-operation lock. Written via ``sudo()`` because direct
writes to ``is_locked`` / ``locked_reason`` are restricted to system
administrators (see ``write``); the pipeline runs as the initiating
non-admin user and must bypass that guard for the two lock fields only."""
# nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped
self.sudo().write({"is_locked": True, "locked_reason": reason})
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

def _release_operation_lock(self):
"""Clear the async-operation lock (see ``_acquire_operation_lock``)."""
# nosemgrep: odoo-sudo-without-context - lock fields admin-write-only (see write()); sudo scoped
self.sudo().write({"is_locked": False, "locked_reason": False})
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

def action_force_unlock(self):
"""Manager-only escape hatch: clear a stuck "Operation in progress" lock.
"""System-administrator-only escape hatch: clear a stuck "Operation
in progress" lock.

Use when an async pipeline (entitlement processing, payment prep, etc.)
died without firing its on_done/on_error callback — for example after
a hard server restart or before this fix was deployed. Posts an audit
line to chatter so admins can see who unstuck the cycle.

The view button is gated to base.group_system, but object methods are
reachable via RPC regardless of button visibility, so the same
restriction is enforced here: clearing an active operation lock while
jobs may still be running is an emergency control reserved for system
administrators. Trusted server-side sudo() flows are exempt.
"""
if not self.env.su and not self.env.user.has_group("base.group_system"):
raise AccessError(
_(
"Force unlock is restricted to system administrators. It is an "
"emergency control for clearing a stuck operation lock; ask a "
"system administrator to confirm the async job has actually "
"stopped before the lock is cleared."
)
)
for rec in self:
if not rec.is_locked:
continue
Expand Down
23 changes: 9 additions & 14 deletions spp_programs/models/managers/cycle_manager_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ def mark_import_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=msg)
except Exception:
Expand All @@ -334,7 +334,7 @@ def mark_import_as_done(self, cycle, msg):
def mark_import_as_failed(self, cycle, msg):
"""Run via on_error() when async beneficiary import fails."""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=msg)
except Exception:
Expand All @@ -351,7 +351,7 @@ def mark_prepare_entitlement_as_done(self, cycle, msg):
:return:
"""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=msg)
except Exception:
Expand All @@ -363,7 +363,7 @@ def mark_prepare_entitlement_as_done(self, cycle, msg):
def mark_prepare_entitlement_as_failed(self, cycle, msg):
"""Run via on_error() when async entitlement preparation fails."""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=msg)
except Exception:
Expand All @@ -379,7 +379,7 @@ def mark_check_eligibility_as_done(self, cycle):
:return:
"""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=_("Eligibility check finished."))
except Exception:
Expand All @@ -391,7 +391,7 @@ def mark_check_eligibility_as_done(self, cycle):
def mark_check_eligibility_as_failed(self, cycle):
"""Run via on_error() when async eligibility check fails."""
self.ensure_one()
cycle.write({"is_locked": False, "locked_reason": False})
cycle._release_operation_lock()
try:
cycle.message_post(body=_("Eligibility check failed."))
except Exception:
Expand Down Expand Up @@ -556,7 +556,7 @@ def _check_eligibility_async(self, cycle, beneficiaries_count):
self.ensure_one()
_logger.debug("Beneficiaries: %s", beneficiaries_count)
cycle.message_post(body=_("Eligibility check of %s beneficiaries started.", beneficiaries_count))
cycle.write({"is_locked": True, "locked_reason": "Eligibility check of beneficiaries"})
cycle._acquire_operation_lock("Eligibility check of beneficiaries")

states = ("draft", "enrolled", "not_eligible")
id_ranges = compute_id_ranges(
Expand Down Expand Up @@ -638,12 +638,7 @@ def prepare_entitlements(self, cycle):
def _prepare_entitlements_async(self, cycle, beneficiaries_count):
_logger.debug("Prepare entitlement asynchronously")
cycle.message_post(body=_("Prepare entitlement for %s beneficiaries started.", beneficiaries_count))
cycle.write(
{
"is_locked": True,
"locked_reason": _("Prepare entitlement for beneficiaries."),
}
)
cycle._acquire_operation_lock(_("Prepare entitlement for beneficiaries."))

id_ranges = compute_id_ranges(
self.env.cr,
Expand Down Expand Up @@ -898,7 +893,7 @@ def add_beneficiaries(self, cycle, beneficiaries, state="draft"):
def _add_beneficiaries_async(self, cycle, beneficiaries, state):
_logger.debug("Adding beneficiaries asynchronously")
cycle.message_post(body=f"Import of {len(beneficiaries)} beneficiaries started.")
cycle.write({"is_locked": True, "locked_reason": _("Importing beneficiaries.")})
cycle._acquire_operation_lock(_("Importing beneficiaries."))

beneficiaries_count = len(beneficiaries)
jobs = []
Expand Down
5 changes: 2 additions & 3 deletions spp_programs/models/managers/eligibility_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ def _import_registrants_async(self, new_beneficiaries, state="draft"):
self.ensure_one()
program = self.program_id
program.message_post(body=f"Import of {len(new_beneficiaries)} beneficiaries started.")
program.write({"is_locked": True, "locked_reason": "Importing beneficiaries"})
program._acquire_operation_lock("Importing beneficiaries")

jobs = []
for i in range(0, len(new_beneficiaries), 10000):
Expand All @@ -168,8 +168,7 @@ def mark_import_as_done(self):
self.ensure_one()
self.program_id.refresh_beneficiary_counts()

self.program_id.is_locked = False
self.program_id.locked_reason = None
self.program_id._release_operation_lock()
self.program_id.message_post(body=_("Import finished."))

def _import_registrants(self, new_beneficiaries, state="draft", do_count=False):
Expand Down
Loading
Loading