Skip to content

security: batch 2 — CR + programs chain (#264, #261, #338, #336, #353, #343, #365) - #422

Open
gonzalesedwin1123 wants to merge 15 commits into
19.0from
19.0-staging-sec-batch2
Open

security: batch 2 — CR + programs chain (#264, #261, #338, #336, #353, #343, #365)#422
gonzalesedwin1123 wants to merge 15 commits into
19.0from
19.0-staging-sec-batch2

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Aug 14, 2026

Copy link
Copy Markdown
Member

Lands security batch 2 — the change-request + programs chain, seven interdependent fixes validated together on a staging branch so 19.0 is never exposed to an unverified intermediate state.

⚠ Merge with a MERGE COMMIT — not squash

Squashing collapses seven distinct security fixes into one opaque commit. The merge commit preserves one commit per fix (each carrying its (#NNN) reference) for bisect and attribution. The repo default is squash — change it at merge time. (Batch 1 / #399 was accidentally squashed; do not repeat.)

What's in it

Seven PRs, hard-ordered because the CR module version chain and a migration dir depend on it: #264#261#338#336#353#343#365.

PR Fix Module → version
#264 Route + apply the same single field for dynamic approvals (freeze proposed change post-submit) spp_change_request_v2 → 3.1.3
#261 Record rules (ownership + area) on every CR detail model spp_change_request_v2 3.1.4, spp_cr_type_assign_program 1.0.2
#338 Validate program access on the assign-program detail (+ apply-sink re-check + preview redaction) spp_cr_type_assign_program → 1.0.3
#336 Enforce system-admin authz on Force Unlock (field guard, not just the button) spp_programs 2.2.2, spp_program_geofence 1.0.1, spp_farmer_registry_demo 2.1.5
#353 Scope program/CR roles to Tier-3 registry read; drop the registry-search menu spp_change_request_v2 3.1.5 (+migration), spp_programs 2.2.3 (+migration)
#343 Writable selected_field no longer bypasses CR conflict/duplicate checks (fail-closed) spp_change_request_v2 → 3.1.6
#365 Server-side manager authorization on CR apply (RPC/UI/wizards/API v2) spp_change_request_v2 → 3.1.7

🔁 Merged 19.0 — version chain renumbered

19.0 shipped #437 as spp_change_request_v2 19.0.3.1.2 after this branch had already claimed that number for #264. Resolved by merging 19.0 in — no rebase, the seven commits are preserved — and shifting the batch-2 chain up one, since 19.0's number is already on mainline and cannot be reused:

Fix Reviewed as Now
#264 3.1.2 3.1.3
#261 3.1.3 3.1.4
#353 3.1.4 3.1.5
#343 3.1.5 3.1.6
#365 3.1.6 3.1.7

#437 keeps 19.0.3.1.2, and its code (strategies/update_id.py, tests/test_update_id_strategy.py) is byte-identical to 19.0 after the merge. The #353 migration directory moved 19.0.3.1.419.0.3.1.5 (100% rename) to match its manifest; a database at 3.1.2 upgrading to 3.1.7 still runs it (installed < script ≤ target). git show --remerge-diff on the merge commit touches exactly __manifest__.py, readme/HISTORY.md, the two generated README files and the migration rename — no security-fix code was modified by the merge, and the delta vs 19.0 is still confined to the five batch-2 modules.

➕ Post-review addition — wizard record rules (19.0.3.1.8)

A /code-review pass over the merged branch found that the three Create-Group member wizards (spp.cr.detail.create_group.member.wizard and its .phone / .bank children) are TransientModels whose ACLs grant group_cr_user read/write/create and unlink, with no ir.rule covering them. Odoo grants transient models no implicit creator-only scoping — ir_rule._compute_domain has no transient branch, so a transient model with no rule resolves to a TRUE domain — so any change-request user could enumerate, read, alter or delete another user's proposed-member names, birthdates, phone numbers and bank account numbers.

This is the same missing-record-rule class as #261, on models #261's completeness test explicitly skipped: it exempted transient models on the strength of the (stale) TransientModel docstring. That skip is removed, which is what then surfaced three additional missing global area-filter rules on the same models.

Added: 12 ownership rules (3 models × 4 roles, scoped through detail_id.change_request_id, perm_unlink included since these ACLs grant it) + 3 global area-filter rules + 7 regression tests. spp_change_request_v219.0.3.1.8. Full module suite: 366 tests, 0 failures.

➕ Post-review addition — duplicate-detection padding (19.0.3.1.9)

_calculate_similarity required the two derived change sets to be identical. Since a dynamic-approval type applies only the routed field, a requester could pad their request with a throwaway edit to another mapped field, make the sets unequal, drop similarity to 0.0 and still have their real change applied unaltered — the evasion was free. Reproduced against a live database: an identical pair scored 100.0 and was flagged; the same pair plus one decoy scored 0.0 and was not; apply wrote only given_name.

Similarity is now scored over the fields both requests propose to change, proportionally, on the 1.0 exact / 0.8 fuzzy scale the static path already uses. Padding falls outside the shared set so it cannot dilute the score, and a mostly identical request no longer collapses to zero the moment one shared field differs.

The derived change set is unchanged — still the detail-versus-registrant diff, never the requester-writable selected_field_name / field_to_modify, which is what keeps a mislabelled request detectable (#343). A test asserts that independence directly instead of relying on #343's suite to catch a regression.

Reachability: gated behind use_dynamic_approval, which no shipped CR type enables (verified: 0 of 2 types in a default database). It is an admin-editable Boolean on the CR-type form, so any deployment that turned it on is affected. spp_change_request_v219.0.3.1.9. Full module suite: 372 tests, 0 failures.

Review

Every fix was independently adversarially reviewed against its exact vulnerability, Odoo 19 core semantics, and test coverage before merging into staging (internal/plans/batch2-fix-review.md): six ACCURATE-AND-COMPLETE with follow-ups only. The one CONCERNS finding — #338 rested on a false "module unreleased" premise, leaving a residual on already-deployed DBs — was fixed in-branch (apply-time sink re-check bound to the requester's company scope + preview redaction + corrected rationale) and that delta separately reviewed.

Verification (on the merged staging tree)

  • Version chain — every module strictly above 19.0 (bases re-measured against current 19.0): CR 3.1.2→3.1.9, programs 2.2.1→2.2.3, assign_program 1.0.1→1.0.3, geofence 1.0.0→1.0.1, farmer_demo 2.1.4→2.1.5. Migration dirs match manifests (CR 19.0.3.1.5, programs 19.0.2.2.3).
  • Structural / file union — the delta vs 19.0 is confined to exactly the 5 batch-2 modules, no riders (18 CR + 19 programs + 10 assign_program + 4 geofence + 5 farmer_demo files).
  • Per-PR CI — each PR was squash-merged into staging pinned with --match-head-commit <CI'd head> (manifest: internal/plans/batch2-manifest.tsv), green on all module test jobs at that head. Each rebase was verified footprint-clean with its code files byte-identical to the reviewed head — only manifest/HISTORY/generated-README metadata was merged.
  • Batch CI — a "Full Test Suite" workflow_dispatch on the staging head is running (linked in checks).
  • Note on check_version_chain.py: run post-merge it reports false "collision" errors, because the seven branches are now stacked (each rebased onto its predecessors) rather than independent off 19.0 — the tool's pre-merge model. The merged-tree checks above are the authoritative post-merge verification.

🔴 Release notes — behavior changes

  1. security(roles): scope program/CR roles to Tier-3 registry read (drop registry-search menu) #353 — Program Viewer / CR Requestor / CR Local Validator / CR HQ Validator lose the Registry Search menu (moved from Tier-2 viewer to Tier-3 read; registrant read unchanged). Migrations revoke the viewer group from existing users on upgrade.
  2. security(programs): enforce system-admin authorization on Force Unlock #336 — Force Unlock on cycles/programs now requires base.group_system server-side; program officers/managers/cycle-approvers can no longer clear an active operation lock via RPC.
  3. security(spp_change_request_v2): enforce manager authorization on CR apply (server-side) #365 — operator action: the API v2 change-request $apply endpoint now requires the endpoint user to hold group_cr_manager. Deployments applying CRs via API must grant that role.
  4. security(cr): route and apply the same single field for dynamic approvals #264 / security(cr): writable selected_field bypasses CR conflict checks #343 — dynamic-approval CRs: the routed field is frozen post-submit and conflict/duplicate detection derives from the actual diff. Latent today (no shipped CR type enables dynamic approval), engages when one is configured.

After merging

Plan & manifest: internal/plans/security-staging-merge-plan.md, internal/plans/batch2-manifest.tsv, internal/plans/batch2-fix-review.md.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.26804% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.89%. Comparing base (82ac4c1) to head (a16c7b6).

Files with missing lines Patch % Lines
spp_change_request_v2/models/conflict_mixin.py 93.87% 3 Missing ⚠️
...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 ⚠️
spp_change_request_v2/models/change_request.py 97.29% 1 Missing ⚠️
...ge_request_v2/models/change_request_detail_base.py 95.65% 1 Missing ⚠️
spp_change_request_v2/strategies/field_mapping.py 90.90% 1 Missing ⚠️
...p_cr_type_assign_program/details/assign_program.py 90.00% 1 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     #422      +/-   ##
==========================================
- Coverage   76.28%   75.89%   -0.40%     
==========================================
  Files         654      626      -28     
  Lines       44035    42952    -1083     
==========================================
- Hits        33592    32597     -995     
+ Misses      10443    10355      -88     
Flag Coverage Δ
spp_api_v2_change_request 66.53% <ø> (ø)
spp_api_v2_cycles 71.03% <ø> (ø)
spp_api_v2_entitlements 70.23% <ø> (ø)
spp_api_v2_programs 92.22% <ø> (ø)
spp_api_v2_simulation 71.19% <ø> (ø)
spp_base_common 91.07% <ø> (ø)
spp_case_entitlements 100.00% <ø> (ø)
spp_case_programs 100.00% <ø> (ø)
spp_cel_load_testing 98.11% <ø> (ø)
spp_change_request_v2 78.12% <95.00%> (+0.28%) ⬆️
spp_cr_type_assign_program 92.50% <95.00%> (+0.42%) ⬆️
spp_dci_client_ibr ?
spp_dci_compliance 93.01% <ø> (ø)
spp_dci_demo 94.28% <ø> (ø)
spp_dci_indicators ?
spp_dci_server_social ?
spp_farmer_registry_cr 61.24% <ø> (ø)
spp_farmer_registry_demo 63.39% <0.00%> (-0.05%) ⬇️
spp_mis_demo_v2 70.38% <ø> (ø)
spp_program_geofence 97.02% <100.00%> (-0.02%) ⬇️
spp_programs 66.89% <89.79%> (+0.16%) ⬆️
spp_registrant_gis ?
spp_registry 87.79% <ø> (ø)
spp_security 69.56% <ø> (ø)

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

Files with missing lines Coverage Δ
...r_type_assign_program/strategies/assign_program.py 94.82% <100.00%> (+1.07%) ⬆️
spp_program_geofence/models/eligibility_manager.py 96.26% <100.00%> (-0.03%) ⬇️
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.47% <100.00%> (ø)
spp_programs/models/programs.py 89.21% <100.00%> (+0.29%) ⬆️
spp_change_request_v2/models/change_request.py 84.14% <97.29%> (+0.30%) ⬆️
...ge_request_v2/models/change_request_detail_base.py 73.75% <95.65%> (+4.26%) ⬆️
... and 7 more

... and 30 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.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Codecov patch-coverage audit — all 16 missing lines reviewed

Codecov flags 16 lines / 91.9% patch coverage. I traced each to its exact line and read the context. None is a security-critical enforcement path — the covered 91.9% includes every actual boundary (freeze write() guards, #365 manager-authz check, #338 program-access constraint + apply-sink re-check, the record rules, the is_locked write guard, and conflict-detection-from-the-real-diff). The 16 misses are defensive branches, latent-feature sub-cases, or mechanical async/demo reroutes.

(The codecov project delta is skewed here as expected for a staging-targeted PR — this audit is of the concrete per-line patch misses, which is the real signal.)

Group A — #336 lock-reroute call sites (8 lines) — acceptable

Mechanical one-line reroutes of pre-existing lock writes onto the new sudo() helpers, in paths the unit-test DB doesn't drive (async entitlement/payment pipeline, farmer-demo generation):

  • spp_programs/models/managers/entitlement_manager_base.py:131, entitlement_manager_inkind.py:217,317, payment_manager.py:334,444cycle._acquire_operation_lock(...)
  • spp_farmer_registry_demo/models/farmer_demo_generator.py:2300,2346 _release_operation_lock(), :2347 a demo state write

The boundary itself (the is_locked write() guard) is tested by spp_programs/tests/test_force_unlock_authz.py. These exact sites were effectively uncovered on 19.0 before the batch too — the fix only changed the line (direct write → helper call), so no new logic is untested.

Group B — CR fixes (8 lines) — defensive/edge, not enforcement

  • conflict_mixin.py:331 return set() — missing-detail/registrant edge guard. The fail-open path returns None (line 327 → full field set); this is a benign edge, not the fail-open.
  • conflict_mixin.py:337, 522 continue — skip a mapping/field referencing a non-existent field (misconfiguration guards).
  • conflict_mixin.py:528 any_similar = True — fuzzy-match sub-branch of dynamic duplicate detection; latent (no shipped CR type enables dynamic approval) and a precision refinement, not a boundary.
  • change_request.py:692 & change_request_detail_base.py:110 value = value.id — recordset-normalization branch of _normalize_frozen_value (only when a frozen field is passed as a recordset); the freeze enforcement itself is tested.
  • strategies/field_mapping.py:34 return mappings.browse() — the fail-CLOSED path (dynamic type, no field selected → applies nothing). The adjacent unmapped-selection branch is tested; this is the safe direction.
  • spp_cr_type_assign_program/details/assign_program.py:65 continue — skip a detail with no program_id; the reject-inaccessible-program logic is tested.

Disposition

No security-critical line is untested, so this is not a merge blocker. A small follow-up will add edge-case tests worth pinning (field_mapping.py:34 no-selection fail-closed; the conflict_mixin dynamic-duplicate any_similar branch); the rest are defensive/mechanical lines left uncovered by design.

Drift back-merge per the staging plan (19.0 advanced 11 commits since the
batch-2 cut: #303, #322, #323, #390, #391, #393, #411, #412, #414, #416,
#433). Conflicts were confined to spp_farmer_registry_demo metadata:
batch 2's #336 demo fix claimed version 19.0.2.1.2, which 19.0 has since
consumed (#412=.2, #322=.3, #323=.4). Resolution re-bumps the batch-2
farmer-demo change to 19.0.2.1.5 with its HISTORY entry moved on top;
README.rst/index.html regenerated via the pinned oca-gen hook. The
change ships no migration directory, so the collision was metadata-only.
The generator code change itself auto-merged cleanly.
gonzalesedwin1123 and others added 4 commits August 20, 2026 17:27
Second drift back-merge (#392, #436, #438 landed on 19.0). Conflicts
confined to spp_programs metadata: 19.0's #436 took 19.0.2.2.1, which the
batch chain had assigned to #336 (with #353 at .2 carrying a migration).
Resolution renumbers the batch chain one notch up: manifest -> 19.0.2.2.3,
HISTORY ladder 2.2.3 = #353, 2.2.2 = #336, 2.2.1 = #436, and
migrations/19.0.2.2.2/ renamed to 19.0.2.2.3/ so the dir matches the
version claiming #353's change in the merged changelog (either name runs
for real upgrade paths; only test DBs ever saw it as 2.2.2).
README.rst/index.html regenerated via the pinned oca-gen hook.
program_manager.py auto-merged; batch 2 (lock helpers) and #436
(protected-state filtering) touch disjoint functions.
Resolve the spp_change_request_v2 version collision: 19.0 shipped
19.0.3.1.2 (#437) after this branch claimed the same number for #264.
19.0's entry keeps 3.1.2; the batch-2 chain shifts up one to 3.1.3-3.1.7,
and the #353 migration directory moves to 19.0.3.1.5 to match.
The is_locked/locked_reason write guard shipped as spp_programs
19.0.2.2.2, not 19.0.2.2.1 — 2.2.1 is the unrelated Enroll Eligible
pause fix already on 19.0. The stale reference dates from the version
bump spp_programs took when 19.0 claimed 2.2.1.
… request

The member wizard and its phone/bank children are transient models whose
ACLs grant change-request users read, write, create and delete, and no
record rule covered them. Odoo grants transient models no implicit
creator-only scoping — ir.rule applies to them as it does to persistent
models, and with no rule the domain resolves to true — so any
change-request user could enumerate, read, alter or delete another
user's proposed-member names, birthdates, phone numbers and bank
account numbers.

Each wizard model now carries the same parent-change-request ownership
rules as the persistent Create-Group detail rows, scoped on every
operation its ACL grants, plus the global area filter.

The completeness test skipped transient models on the strength of the
same false premise; removing that skip is what surfaced the missing
area-filter rules.
Comparison required the two derived change sets to be identical. A
dynamic-approval type applies only the routed field, so a requester
could pad their request with a throwaway edit to another mapped field,
make the sets unequal, drop similarity to zero and still have their real
change applied unaltered — the evasion cost nothing. Confirmed against a
live database: an identical pair scored 100 and was flagged, the same
pair plus one decoy scored 0 and was not, and apply wrote only the
routed field.

Similarity is now scored over the fields both requests propose to
change, proportionally, on the 1.0 exact / 0.8 fuzzy scale the static
path already uses. Padding falls outside the shared set, so it cannot
dilute the score, and a mostly identical request no longer collapses to
zero the moment one shared field differs.

The change set itself is untouched: still derived from the
detail-versus-registrant diff and never from the requester-writable
selected_field_name or field_to_modify, which is what keeps a
mislabelled request detectable. A test now asserts that independence
directly rather than relying on the #343 suite to catch a regression.
19.0 released spp_programs 19.0.2.3.0 (deduplication, #445), which lands
above the 2.2.2 / 2.2.3 this branch claimed. Left alone the manifest
would regress to 2.2.3 and — worse — the #353 role-repointing migration
in migrations/19.0.2.2.3 would never run on a database already upgraded
to 2.3.0, since Odoo only runs scripts where installed < script <=
target.

The batch's two spp_programs fixes move above 2.3.0: #336 to 19.0.2.3.1
and #353 to 19.0.2.3.2, with the migration directory renamed to match.
19.0's 2.3.0 entry keeps its number and its code is untouched.
kneckinator added a commit that referenced this pull request Aug 25, 2026
19.0 released spp_programs 19.0.2.3.0 (deduplication, #445), above the
19.0.2.2.4 this branch claimed. Left alone the manifest would regress
and the new Tier-3 access-control entries would never be applied to a
database already upgraded to 2.3.0.

This branch's spp_programs entry moves to 19.0.2.3.3, above the
19.0.2.3.1 / 19.0.2.3.2 that the security batch-2 branch (#422) takes,
so the two stack cleanly with #422 merging first.

The access-control file conflict is a both-sides append: 19.0's
deduplication wizard entries and this branch's Tier-3 registry-read
entries are both kept. The two new entries are also renamed from
access_spp_cycle_registry_registry_read /
access_spp_cycle_membership_registry_registry_read, which doubled a word
through a generation slip; nothing references the old identifiers and
neither has shipped.
Local README regeneration renders two RST table columns one percent
different from CI, and the routine for discarding that difference only
reverted the first hunk of a file. In 44a3cd7 the whole diff was that
rendering difference spread over several hunks, so one was reverted and
the rest committed, leaving spp_change_request_v2's index.html out of
sync with what oca-gen-addon-readme produces.

Only the column widths are restored; the changelog anchors regenerated
alongside them are correct and kept.
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