Skip to content

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

Merged
kneckinator merged 22 commits into
19.0from
19.0-staging-sec-batch2
Aug 26, 2026
Merged

security: batch 2 — CR + programs chain (#264, #261, #338, #336, #353, #343, #365)#422
kneckinator merged 22 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.

➕ Post-review additions — findings #6-#13 (19.0.3.1.10, programs 19.0.2.3.3)

A /code-review pass over the merged branch raised 14 findings; #3, #4 and #14 were handled above and in follow-ups. The remaining eight were each verified against a live database before being fixed — two turned out to be misdescribed by the review, and one was refuted outright.

Finding Fix
Operation lock guarded on write only Guarded on create too, on spp.cycle and spp.program. A program officer could create an already-locked record and then be unable to clear it — confirmed live on both models.
apply() reported success having written nothing Applying now raises when no mapping can be written, so the request is not stamped applied with an audit event and a log line for a change that was dropped. A genuine no-op still applies.
"" vs False in the post-submit freeze An integration re-saving a record sends "" for an empty Char, which did not match the stored False, so an idempotent re-save was rejected as a content change. The normalisation existed verbatim in two places and now lives once.
detail_res_id freeze blocked _ensure_detail() A submitted request with no detail row could not be opened or repaired from any context, sudo included. Binding is now allowed, but only to a row already pointing at that request, so a substituted detail still cannot be attached.
Detection and apply disagreed on "changed" Detection folded case and whitespace and ignored transform expressions while apply compares raw, so a cosmetic edit was invisible to conflict detection yet still written. Both now share one comparison.
Duplicate-detection recomputation The caller's change set is derived once per run rather than per candidate.

Two corrections to the review worth recording: the ""/False trigger it described does not exist (_format_value_for_display returns "No", not "", for an unset field — the reachable path is an external write payload), and its claim that the apply-time program-access check is weaker than the write-time one has no trigger in the shipped system — a live enumeration found exactly one ir.rule on spp.program across 93 installed modules, and it is the company rule.

Suites on the merged tree: spp_change_request_v2 395, spp_programs 739, zero 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.10, programs 2.3.0→2.3.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.3.2). 19.0 released programs 2.3.0 (deduplication, feat(spp_programs): deduplication — configure by method, recompute on each run, bulk return to draft #445) mid-review, so the batch's programs entries were renumbered above it — left at 2.2.x the manifest would have regressed and the security(roles): scope program/CR roles to Tier-3 registry read (drop registry-search menu) #353 role migration would never have run on a database already at 2.3.0.
  • 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 93.28063% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.93%. Comparing base (82ac4c1) to head (3079d76).

Files with missing lines Patch % Lines
spp_change_request_v2/models/conflict_mixin.py 92.45% 4 Missing ⚠️
spp_change_request_v2/models/change_request.py 94.11% 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/strategies/field_mapping.py 96.96% 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.93%   -0.35%     
==========================================
  Files         654      627      -27     
  Lines       44035    43000    -1035     
==========================================
- Hits        33592    32654     -938     
+ Misses      10443    10346      -97     
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.53% <95.06%> (+0.69%) ⬆️
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.97% <92.42%> (+0.23%) ⬆️
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 Δ
...ge_request_v2/models/change_request_detail_base.py 73.72% <100.00%> (+4.23%) ⬆️
spp_change_request_v2/models/frozen_value.py 100.00% <100.00%> (ø)
...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.66% <100.00%> (+1.18%) ⬆️
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.39% <100.00%> (+0.48%) ⬆️
... and 8 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.
The is_locked / locked_reason guard covered write only, so a program
officer could create a cycle or program already locked, bypassing it
entirely. Clearing the lock afterwards goes through the guarded write,
so the creator could not undo it without a system administrator — a
self-inflicted lockout, confirmed live on both models.

The check moves into _assert_operation_lock_writable and is called from
create and write, rather than pasting the condition and its message a
third and fourth time. sudo() and system administrators are unaffected,
so the async pipeline that creates through sudo keeps working.
field_mapping.apply() returned True even when it had no mapping to
write, so the request was stamped applied — with applied_date, an audit
event and a log line — having changed nothing. Operators saw a green,
applied request whose change had been silently dropped. This happens
when the field a dynamic-approval request was routed on loses its
mapping, or when the type has none configured, because
_effective_mappings fails closed.

Applying now raises in that case, so _apply_change_request records
apply_error and leaves the request unapplied. A genuine no-op is
unaffected and still succeeds: the mappings exist, the registrant simply
already holds the proposed values.

The fail-closed test from #343 keeps its guarantee — nothing is written
for an unmapped selection — and now also expects the rejection.
Odoo stores an unset Char as False, but a JSON-RPC client or integration
re-saving a record sends "". The freeze guards normalised only None, so
"" did not match the stored False and an idempotent re-save was rejected
with the lockout error as though it had altered the approved content.

The normalisation also existed twice, verbatim, on spp.change.request
and on spp.cr.detail.base — so the gap had to be closed in both places
or the two guards would disagree about what counts as a change. It now
lives once in models/frozen_value.py, deliberately model-free:
change_request is imported before change_request_detail_base, so having
either import the other would tie the freeze to model registration
order.

Clearing a populated frozen field with "" is still rejected; "" reads as
unset, not as a licence to blank an approved value.
detail_res_id is frozen once a request leaves draft so a substituted
detail cannot be attached after approval, but the guard compared old
against new without telling binding apart from re-pointing. The
legitimate False -> id transition was refused too, and the guard has no
sudo exemption, so a submitted request that never got a detail row could
not be opened or repaired from any context — _ensure_detail() performs
exactly that transition.

Exempting False -> id outright would let an arbitrary detail be attached
to an approved request that happens to have none, which is what the
freeze exists to prevent. Binding is therefore accepted only for a row
that already points back at this request; _ensure_detail() creates the
detail with that link before setting the pointer, so the repair path
qualifies and a foreign detail id does not.
Conflict and duplicate detection decided whether a mapped field changed
using _normalize_field_value, which lowercases and strips strings, while
apply compares raw. A case- or whitespace-only edit was therefore
invisible to detection yet still written to the registrant, so a
field-scoped conflict rule could be sidestepped by a cosmetic edit.
Detection also ignored transform expressions, which apply evaluates
before comparing.

The comparison moves onto the strategy as current_target_value /
proposed_target_value / mapping_changes_value, and detection now asks it,
so the two cannot drift apart again. Every configured mapping is still
considered, not just the routed one: narrowing to selected_field_name
would put the change set back under the requester's control.

Similarity scoring keeps using _normalize_field_value — being
case-insensitive is the point of a fuzzy match. Only the "did this
change" derivation had to match apply.

Also derives the caller's change set once per duplicate run instead of
recomputing it for every candidate; it does not vary by candidate and
each derivation re-browses the detail and re-reads every mapping.
spp_change_request_v2 -> 19.0.3.1.10 (detection/apply comparison, apply
no-op rejection, detail re-binding, empty-string freeze normalisation,
duplicate-run memoisation) and spp_programs -> 19.0.2.3.3 (operation
lock on create).

One bump per module rather than per fix: the per-fix versions in this
batch came from each fix being its own PR, and these land together here.
spp_programs takes 2.3.3, so the Tier-3 access-control follow-up moves to
2.3.4.
kneckinator added a commit that referenced this pull request Aug 26, 2026
The security batch-2 branch (#422) now also takes 19.0.2.3.3, for
guarding the operation lock on create as well as write, so this branch
moves up one to stay above it. Still assumes #422 merges first.
Comment thread spp_change_request_v2/models/change_request.py Fixed
semgrep's odoo-sudo-without-context flagged the ownership lookup added
for re-binding a detail row. The sudo is deliberate: the caller may hold
no access to the detail model, and the result is only ever used to accept
or reject the write — the record is never returned or exposed. Annotated
in the same way as the other intentional sudo calls in this module, with
the reasoning inline.
@kneckinator

Copy link
Copy Markdown
Contributor

Follow-up PRs

Recording the follow-up work so it isn't lost once this merges.

Open now

PR What Relationship to this PR
#454 Tier-3 group_registry_read reaches the registrant form's models Fixes a regression introduced by #353 here. #353 should not reach production without it, or Tier-3-scoped roles lose the registrant form.
#459 Field-mapping transform expressions evaluate again (safe_eval took no nocopy in Odoo 19, so every transform silently no-opped) Independent of this PR; surfaced while reconciling detection with apply. Behaviour-changing on upgrade — see its description.
#460 Authorization failures on the change-request API return 403 instead of 409 Corrects how the manager requirement added by #365 here is reported to API clients.

Merge orderspp_programs and spp_change_request_v2 versions are stacked for this PR → #454#459. #460 is independent. A different order needs renumbering, so it's worth landing them while the chain holds.

Still to open

Two upstream items came out of reviewing this branch. Both are backwards-compatible, so neither blocks this merge, but both should land before the next packaged release.

1. Auto-apply no longer runs the action_apply extension point. #365 routes auto_apply_on_approve through _apply_change_request() so a validator approving doesn't trip the new manager gate. But action_apply has long been the apply extension point, so any downstream module overriding it silently stops running on auto-apply — no error, just missing side effects. The gate already exempts self.env.su (and its docstring says so), so this is a one-line fix:

# _on_approve
-            self._apply_change_request()
+            self.sudo().action_apply()

Worth treating as a priority: it fails silently, and it affects every consumer that extends action_apply, not just one.

2. Dynamic routing keys are assumed 1:1 with mapping source fields. _effective_mappings (added by #264) narrows apply to m.source_field == selected_field_name, but the routing key comes from _get_field_to_modify_selection(), which is a display-level choice. A type that routes on one logical field applied through several mappings — e.g. a composite name captured as three components — matches no mapping and applies nothing. Suggested fix, additive and migration-free:

# spp.change.request.type.mapping
routing_field = fields.Char(help="Routing key this mapping serves. Defaults to source_field.")

# _effective_mappings
mappings.filtered(lambda m: (m.routing_field or m.source_field) == selected)

Worth pairing with a config-time check that, for use_dynamic_approval + field_mapping types, every selectable routing key resolves to at least one mapping — for that strategy an unmapped selectable field can never be applied, so it is always a misconfiguration, and catching it at install beats catching it at apply.

Note the fix in this PR that rejects an apply which can write nothing turns case 2 from silent data loss into a visible error. That's the safer failure mode, but affected configurations still need the mapping fix.

Already tracked

#403 (custom-strategy content freeze — this batch adds another affected type, commented there), #337, #344, #346, #347.

@kneckinator
kneckinator merged commit 380b045 into 19.0 Aug 26, 2026
56 checks passed
@kneckinator
kneckinator deleted the 19.0-staging-sec-batch2 branch August 26, 2026 07:49
kneckinator added a commit that referenced this pull request Aug 26, 2026
Absorbs the security batch-2 merge (#422), which took spp_programs to
19.0.2.3.3. This branch keeps 19.0.2.3.4, so no renumbering was needed —
the version stack held.

All conflicts are metadata: the manifest version, both sides of the
changelog, both sides of the test registrations, and the two generated
README files.
kneckinator added a commit that referenced this pull request Aug 26, 2026
Absorbs the security batch-2 merge (#422), which took
spp_change_request_v2 to 19.0.3.1.10. This branch keeps 19.0.3.1.11, so
no renumbering was needed — the version stack held.

All conflicts are metadata: the manifest version, both sides of the
changelog, both sides of the test registrations, and the two generated
README files.

Worth noting the combination: #422 made conflict/duplicate detection
share the apply strategy's value comparison, and this branch makes
transform expressions actually evaluate. Together, detection now honours
transforms for the first time. The invariant test added in #422 —
detection agrees with whether apply wrote anything — passes unchanged.
gonzalesedwin1123 pushed a commit that referenced this pull request Aug 27, 2026
…odels (#454)

* fix(security): let Tier-3 registry read reach the registrant form's models

spp_registry.group_registry_viewer (Tier-2) implies group_registry_read
(Tier-3), so any model granted only to the viewer tier becomes
unreadable for a role scoped to the read tier. Several modules that
extend the registrant form grant their models to the viewer tier alone,
so such a role gets an AccessError merely opening a registrant.

Verified per model against a live database rather than by reading ACL
files: spp_consent's nine models, spp_programs' spp.cycle and
spp.cycle.membership (the entitlement lists render cycle_id) and
spp_irrigation's spp.irrigation.asset are genuinely unreachable.
res.partner.bank is not, despite being viewer-only here — Odoo core
already grants it to base.group_user — so spp_banking needs no change.

spp_programs skips 19.0.2.2.2 and .2.2.3, which the security batch-2
branch claims; this assumes that branch merges first.

Also mounts docker/postgresql.conf with :ro,z. Without the SELinux
relabel flag the database container cannot read it from a fresh git
worktree, which breaks scripts/test_single_module.sh there.

* chore: move spp_programs to 19.0.2.3.4

The security batch-2 branch (#422) now also takes 19.0.2.3.3, for
guarding the operation lock on create as well as write, so this branch
moves up one to stay above it. Still assumes #422 merges first.

* chore: restore spp_change_request_v2 README to the generator's output

Regenerating the README fragments after merging 19.0 rewrote every
module, not just the one this branch touches, and the local renderer
lays RST table columns out one character wider than CI does. Only
spp_programs was corrected, so an unrelated spp_change_request_v2 diff
rode along. Reverted to 19.0's version — this branch does not touch that
module.

* fix(security): address review — drop the wizard grants, fix the ACL xmlids

Two corrections from review.

The two consent wizard grants are dropped. Neither wizard is a
registrant-form dependency by this branch's own criterion — both Record
Consent entry points are gated to the officer and manager tiers, and
read without create cannot open a wizard anyway — so the grant buys a
Tier-3 user nothing. It is not inert elsewhere: the bulk wizard's action
binds to res.partner with no group_ids, and get_bindings filters only on
read access to the action's model, so the row would surface "Bulk Record
Consent" in the registrant list for read-only users who then fail on
click. Odoo also enforces no per-user isolation on transient models, so a
model-level read grant exposes other users' in-flight wizard rows.
Restoring pre-#353 parity here would have restored a bug.

The irrigation ACL identifiers now follow the documented
access_{model}_{group} convention: the misnamed pre-existing row becomes
..._registry_viewer, and the new Tier-3 row takes ..._registry_read. The
claim that renaming would orphan the old xmlid was wrong — CSV rows load
with noupdate false, and _process_end removes exactly those identifiers
when a reloaded module no longer declares them — so the rationale is
dropped from the changelog rather than restated.

Also from review: the spp.cycle.membership reason now names the real
dependency (latest_cycle_state, computed by searching that model as the
acting user) rather than cycle_id; the consent changelog records why the
wizards are excluded; Command.link() replaces (4, id) tuples per the
Odoo 19 compatibility guidance; spp_consent/tests/__init__.py is
alphabetical again; and ./e2e/reports takes ,z, the last bind mount
without it.
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.

3 participants