Skip to content

security(programs): enforce system-admin authorization on Force Unlock#336

Draft
gonzalesedwin1123 wants to merge 9 commits into
19.0from
security-programs-force-unlock-authz
Draft

security(programs): enforce system-admin authorization on Force Unlock#336
gonzalesedwin1123 wants to merge 9 commits into
19.0from
security-programs-force-unlock-authz

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Jul 23, 2026

Copy link
Copy Markdown
Member

Do not merge yet — held for human review.

Summary

The async operation lock (is_locked / locked_reason on spp.cycle and spp.program) protects in-flight entitlement / payment / eligibility jobs, and the Force Unlock buttons are gated to base.group_system in the views. But there was no server-side enforcement: action_force_unlock did no group check, and — more fundamentally — is_locked / locked_reason were plain writable fields. Because ir.model.access.csv grants write on both models to group_programs_officer, group_programs_manager, and group_programs_cycle_approver, any of those roles could clear an active lock via RPC — either by calling action_force_unlock, or simply:

env['spp.cycle'].browse(cycle_id).write({'is_locked': False})

…enabling conflicting operations and the integrity/availability risks in benefit processing (double entitlements/payments, racing eligibility recompute). Shipped in released tag 2026.07.

Fix — the lock is now a real server-side boundary

  • write() guard on spp.cycle and spp.program: a direct write to is_locked / locked_reason raises AccessError unless self.env.su or the caller holds base.group_system. This closes the direct-write sink, not just the labeled button.
  • action_force_unlock keeps its base.group_system method guard (its write runs in admin context and passes the write() guard).
  • Pipeline still works for non-admins: the async managers acquire/release the lock through new _acquire_operation_lock / _release_operation_lock helpers that sudo(), so the ~25 legitimate acquire/release sites keep working when a job runs as the initiating (non-admin) user. All lock writes were lock-only dicts, so the sudo() touches nothing else.
  • Authorization level = base.group_system, matching the view gating and the report's intent (managers are explicitly excluded). Stale "manager-only" docstrings/comment corrected.
  • No ACL change (write on the models is legitimate for normal editing; only the lock fields are gated) and no migration (runtime check, no schema/data change).
  • Version bump 19.0.2.2.0 → 19.0.2.2.1 + HISTORY.md fragment.

Tests (TDD)

New spp_programs/tests/test_force_unlock_authz.py:

  • action_force_unlock and a direct write({'is_locked': ...}) raise AccessError for officer / manager / cycle approver — and the lock stays set; setting the lock out of band is blocked too.
  • A base.group_system user (and sudo()) succeed via both paths.
  • The sudo() helpers still release/acquire the lock for a non-admin operating user (pipeline regression).
  • A manager editing a non-lock field while the cycle is locked still succeeds (guard is scoped to the lock fields).
  • Existing behavior + async-lock-recovery tests run as admin and stay green.
  • Green: spp_programs 692/692 (0 failed); ruff/ruff-format/pylint-odoo clean.

History of this PR

The first revision guarded only action_force_unlock. An independent staff review flagged that the plain-field direct write bypassed it entirely — our own "guard every sender-reachable path to the same sink" pitfall. This revision closes the field sink and makes the lock an enforced boundary.

Residual, tracked separately

The async completion callbacks mark_*_as_done / mark_*_as_failed are public and RPC-reachable and (now via the sudo() helper) clear the lock; they must run as the non-admin operating user, so they can't take the base.group_system gate and need a job-identity fix instead. Tracked in #337 — this PR does not close that path, and does not claim to.

Footprint note (Option A ripple into dependent modules)

Because the write() boundary lives on spp.program/spp.cycle, every writer of the lock fields repo-wide must go through the sudo helpers, or it hits the guard. A staff review caught that the first Option-A revision missed several — now fixed, which grows this PR to three modules:

  • spp_programs — main fix; also converted eligibility_manager.mark_import_as_done, which cleared the lock via attribute assignment (self.program_id.is_locked = False) in the on_done callback that runs as the non-admin operating user.
  • spp_program_geofence (released, 19.0.1.0.0 → 19.0.1.0.1) — its async import acquired/released the spp.program lock directly and would have raised AccessError for a non-admin import under the new guard. Routed through the inherited helpers.
  • spp_farmer_registry_demo (released, 19.0.2.1.1 → 19.0.2.1.2) — the demo generator cleared the cycle lock directly; it only worked because demo generation runs as admin. Converted to the helper to remove the latent trap.

Exhaustive grep confirms the only remaining direct writes to is_locked/locked_reason on spp.cycle/spp.program are the two admin-guarded action_force_unlock methods. (spp_area and spp_demo have their own is_locked fields on unrelated models — not affected.) Suites green: spp_programs 693, spp_program_geofence 41, spp_farmer_registry_demo 132.

Merge order

Per the cross-PR interaction analysis (2026-07-24, internal/plans/security-prs-interaction-analysis.md): this PR merges before #341 on spp_farmer_registry_demo (this PR takes 19.0.2.1.2; #341 already reserves .1.3). If the order is ever reversed, this PR must renumber to .1.4. After this PR lands, #341 flips to CONFLICTING (metadata-only: manifest/HISTORY/README) and silently stops getting CI — re-merge it immediately. Semantics are complementary: #341's non-admin demo users keep working under this PR's write() guard precisely because this PR converts the demo generator's lock writes to the sudo helper. Also note: soft preference to land #327/#329 (admin-group de-escalations) in the same release — until they land, dci/key admins still transit base.group_system and pass this PR's new force-unlock gate.

TDD red: action_force_unlock on spp.cycle/spp.program has no server-side
authorization, so officers, managers and cycle approvers (who hold write
ACL) can clear an active operation lock via RPC. New tests assert the
call raises AccessError for those roles and leaves the lock set, while
system admins and trusted sudo() flows still succeed.
action_force_unlock on spp.cycle and spp.program cleared an active
operation lock (is_locked) with no server-side authorization. The view
buttons are gated to base.group_system, but object methods are reachable
via RPC/call_kw regardless of button visibility, and officers, managers
and cycle approvers hold write ACL on these models - so any of them could
force-clear a lock while async entitlement/payment/eligibility jobs were
still running, enabling conflicting operations and integrity/availability
issues.

- guard both methods with base.group_system (fail fast, before the loop),
  mirroring the existing has_group/AccessError idiom in cycle.py; exempt
  self.env.su so trusted server-side sudo() flows are not blocked
- correct the misleading 'manager-only' docstrings and test comment to
  system-administrator-only
- no ACL change (write is legitimate for normal cycle/program work; the
  method is the exposure) and no migration (runtime check, no schema/data
  change)
- bump to 19.0.2.2.1 with HISTORY fragment
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Scope follow-up filed: #337 — the async mark_*_as_done/mark_*_as_failed completion callbacks also clear the operation lock and are RPC-reachable, but must run as the non-admin operating user, so they need a different (job-identity) fix rather than the base.group_system guard used here. Tracked separately so this PR stays scoped to the manual escape hatch.

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.18519% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 71.42%. Comparing base (1caf794) to head (051d8ae).
⚠️ Report is 3 commits behind head on 19.0.

Files with missing lines Patch % Lines
...rmer_registry_demo/models/farmer_demo_generator.py 0.00% 3 Missing ⚠️
...rams/models/managers/entitlement_manager_inkind.py 0.00% 2 Missing ⚠️
spp_programs/models/managers/payment_manager.py 50.00% 2 Missing ⚠️
...ograms/models/managers/entitlement_manager_base.py 80.00% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #336      +/-   ##
==========================================
- Coverage   74.28%   71.42%   -2.86%     
==========================================
  Files         372      211     -161     
  Lines       25385    13509   -11876     
==========================================
- Hits        18857     9649    -9208     
+ Misses       6528     3860    -2668     
Flag Coverage Δ
spp_analytics ?
spp_api_v2_cycles 71.03% <ø> (+0.38%) ⬆️
spp_api_v2_entitlements 70.23% <ø> (+0.27%) ⬆️
spp_api_v2_gis ?
spp_api_v2_programs 92.22% <ø> (+0.19%) ⬆️
spp_api_v2_simulation 71.19% <ø> (+0.07%) ⬆️
spp_audit_programs ?
spp_base_common 91.07% <ø> (+0.80%) ⬆️
spp_case_demo ?
spp_case_entitlements 100.00% <ø> (+2.38%) ⬆️
spp_case_programs 100.00% <ø> (+2.85%) ⬆️
spp_cr_type_assign_program 92.07% <ø> (+0.90%) ⬆️
spp_dci_compliance 93.01% <ø> (+0.24%) ⬆️
spp_dci_demo 94.28% <ø> (+0.88%) ⬆️
spp_dci_server_social 89.57% <ø> (+0.19%) ⬆️
spp_demo ?
spp_demo_phl_luzon ?
spp_drims ?
spp_drims_sl ?
spp_drims_sl_demo ?
spp_farmer_registry_demo 61.02% <0.00%> (ø)
spp_gis_report ?
spp_gis_report_programs 100.00% <ø> (?)
spp_grm_demo 81.43% <ø> (+1.56%) ⬆️
spp_grm_programs 92.13% <ø> (?)
spp_indicator ?
spp_indicator_studio ?
spp_metric_service ?
spp_mis_demo_v2 ?
spp_program_geofence 97.02% <100.00%> (?)
spp_programs 65.44% <89.79%> (+0.17%) ⬆️
spp_registry 86.94% <ø> (+0.10%) ⬆️
spp_security 69.56% <ø> (+2.89%) ⬆️
spp_simulation ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
spp_program_geofence/models/eligibility_manager.py 96.26% <100.00%> (ø)
spp_programs/models/cycle.py 66.11% <100.00%> (+0.63%) ⬆️
spp_programs/models/managers/cycle_manager_base.py 72.65% <100.00%> (ø)
...pp_programs/models/managers/eligibility_manager.py 87.70% <100.00%> (+3.96%) ⬆️
...ograms/models/managers/entitlement_manager_cash.py 64.45% <100.00%> (ø)
spp_programs/models/managers/program_manager.py 86.39% <100.00%> (ø)
spp_programs/models/programs.py 87.81% <100.00%> (+0.34%) ⬆️
...ograms/models/managers/entitlement_manager_base.py 74.41% <80.00%> (ø)
...rams/models/managers/entitlement_manager_inkind.py 55.74% <0.00%> (ø)
spp_programs/models/managers/payment_manager.py 66.66% <50.00%> (ø)
... and 1 more

... and 197 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Follows the staff review of the initial method-only guard: is_locked /
locked_reason were plain writable fields, so officers/managers/approvers
(who hold write ACL) could bypass the action_force_unlock guard entirely
with a direct write({'is_locked': False}) over RPC.

- add a write() guard on spp.cycle and spp.program rejecting direct
  changes to is_locked/locked_reason unless self.env.su or the caller is
  base.group_system; this is the actual sink behind Force Unlock
- route the async pipeline's lock acquire/release through new
  _acquire_operation_lock/_release_operation_lock helpers that sudo(), so
  the ~25 manager call sites (entitlement/payment/eligibility/cycle) keep
  working when the job runs as the initiating non-admin user
- action_force_unlock keeps its base.group_system method guard (its write
  runs in admin context and passes the new write() guard)
- tests: direct write({'is_locked': ...}) by officer/manager/approver
  raises AccessError and the lock stays; admin/sudo succeed; the sudo
  helpers still work for a non-admin operating user; non-lock field edits
  by a manager are unaffected
- HISTORY updated to describe the field-level boundary

The mark_*_as_done/_failed completion callbacks remain callable via RPC
(they must run as the operating user) and are tracked separately in #337.
Comment thread spp_programs/models/cycle.py Fixed
Comment thread spp_programs/models/cycle.py Fixed
Comment thread spp_programs/models/programs.py Fixed
Comment thread spp_programs/models/programs.py Fixed
The _acquire/_release_operation_lock helpers sudo() the write of the two
lock fields because the write() guard restricts them to system admins
while the async pipeline runs as the initiating non-admin user. Suppress
odoo-sudo-without-context with justification (sudo is scoped to the two
lock fields only).
Staff review of Option A found the pipeline conversion was incomplete: I
had only converted dict-style .write({'is_locked':...}) sites in
spp_programs/models/managers, missing attribute-assignment writes and
dependent modules. The new spp.program/spp.cycle write() guard would
raise AccessError for the non-admin operating user at those sites,
leaving programs stuck-locked or breaking async imports.

- spp_programs eligibility_manager.mark_import_as_done: the on_done
  callback cleared the lock via self.program_id.is_locked = False
  (attribute assignment) — now uses _release_operation_lock()
- spp_program_geofence (released): its _import_registrants_async /
  mark_import_as_done wrote the spp.program lock directly and broke for
  non-admin imports under the guard; routed through the inherited
  helpers; version 19.0.1.0.0 -> 19.0.1.0.1 + HISTORY
- spp_farmer_registry_demo (released): demo generator cleared the cycle
  lock directly (works only because demo runs as admin) — converted to
  the helper to remove the latent trap; version 19.0.2.1.1 -> 19.0.2.1.2
  + HISTORY
- add a regression test driving the real eligibility mark_import_as_done
  as a non-admin officer (would have caught the miss)

Verified: exhaustive grep shows the only remaining direct lock writes on
spp.cycle/spp.program are the two admin-guarded action_force_unlock
methods. Suites: spp_programs 693, spp_program_geofence 41,
spp_farmer_registry_demo 132 — all green.
Staff-review nit: the callback-vector lock-release residual (public
mark_* methods can clear the lock via RPC) was disclosed only in a commit
message. Add a NOTE(#337) comment by the helpers so a maintainer reading
the source sees the known, tracked gap.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants