Skip to content

security(cr): route and apply the same single field for dynamic approvals#264

Open
gonzalesedwin1123 wants to merge 4 commits into
19.0from
security-cr-dynamic-approval-field
Open

security(cr): route and apply the same single field for dynamic approvals#264
gonzalesedwin1123 wants to merge 4 commits into
19.0from
security-cr-dynamic-approval-field

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Do not merge yet — held for human review.

Summary

Dynamic-approval CR types route/approve on a single selected field (selected_field_name, synced from the detail's field_to_modify), but the field_mapping apply strategy iterated over all apply_mapping_ids and wrote every changed detail field to the registrant under sudo(). A CR user could select a low-risk field (routing to a weak/local approval), also change high-risk mapped fields (birthdate, identifiers, …), obtain the weak approval, and have all changed fields written — a privilege-escalation bypass of the tiered approval routing. (field_mapping also treats empty detail values as intentional clears, so non-selected fields could be wiped.)

Fix (two layers)

1. Apply-restriction — for dynamic-approval CR types, field_mapping apply() and preview() operate only on the mapping whose source_field == selected_field_name (the routed + approved field). Fail-closed: a missing/unmapped selection applies nothing. Non-dynamic types are unchanged; the custom strategy is out of scope (author-controlled writes).

2. Post-submit freeze — the apply-restriction alone left a time-of-check/time-of-use gap: the selection and detail stayed mutable after routing (the views already made them readonly once submitted, but that was UI-only). Enforced server-side:

  • spp.cr.detail.base.write() rejects changes to proposed-change fields (field_to_modify + field_mapping source fields) once approval_state not in ('draft','revision'). Apply-output fields (created_*_id) are not protected, so apply strategies still record results post-approval.
  • spp.change.request.write() rejects changes to selected_field_name/old/new value and detail_res_id/detail_res_model once submitted — get_detail() resolves the detail strictly by that pointer for both routing and apply, so freezing it prevents substituting a second detail record.

Editing requires resetting to draft, which re-routes the approval.

Tests

  • Apply-restriction: dynamic CR applies/previews only the selected field even when another mapped field changed; unmapped selection writes nothing; non-dynamic CRs still apply all mappings.
  • Freeze: field-swap, direct selected_field_name write, value-swap, and detail-substitution (repoint detail_res_id) are all rejected post-submit; draft edits still allowed.
  • Green: spp_change_request_v2 307/307; downstream custom-detail modules spp_cr_type_assign_program 19/19, spp_farmer_registry_cr 95/95 (apply's post-approval created_* writes unaffected). Lint/semgrep clean.

Reviewed

Three adversarial staff-review passes. The first two each found a real residual bypass — (a) the initial apply-restriction left a TOCTOU field/value swap; (b) then a detail-substitution via detail_res_id repoint — both fixed. Final pass: APPROVE, substitution closed on every detail-resolution path, no new breakage.

Follow-up (out of scope)

Freeze the CR's identity fields post-submit too — registrant_id (swap → apply to a different person) and request_type_id — same "frozen once approved" class, but needs an env.su discriminator (not a client-forgeable context flag) because the create_group apply strategy legitimately writes registrant_id post-approval. Tracked for a deliberate follow-up.

Dynamic-approval CR types route and approve based on a single selected field
(selected_field_name), but the field_mapping apply strategy iterated over ALL
apply_mapping_ids and wrote every changed detail field to the registrant under
sudo(). A CR user could select a low-risk field (routing to a weak/local
approval), also change high-risk mapped fields on the detail, obtain the weak
approval, and have every changed field written — a privilege-escalation bypass
of the tiered approval routing. (field_mapping also treats empty detail values
as intentional clears, so non-selected fields could even be wiped.)

Restrict field_mapping to the selected field's mapping for dynamic-approval CR
types, in both apply() and preview() (so the approver preview matches what is
written). Fail closed: a missing or unmapped selection applies nothing.
Non-dynamic CR types are unchanged. The custom apply strategy is out of scope
(author-controlled writes).

Tests: a dynamic CR applies/previews only the selected field even when another
mapped field changed; an unmapped selection writes nothing; non-dynamic CRs
still apply all changed mappings. spp_change_request_v2 302/302.
Follow-up to the apply-time restriction: close the time-of-check/time-of-use
desync where the routed change stayed mutable after submission. A user could
route on a low-risk field (weak approval), then before apply either swap the
selected field, change the routed field's value, or substitute the detail
record — applying an unapproved field/value under the weaker approval. The
views already made these readonly once approval_state left draft/revision; this
enforces the same on the server so it cannot be bypassed via RPC.

- spp.cr.detail.base.write(): reject changes to proposed-change fields
  (field_to_modify + field_mapping source fields) when the CR is not in
  draft/revision. Apply-output fields (created_*_id) are not protected, so apply
  strategies still record results post-approval.
- spp.change.request.write(): once submitted, reject changes to
  selected_field_name/old/new value AND detail_res_id/detail_res_model.
  get_detail() resolves the detail strictly by that pointer for both routing and
  apply, so freezing it prevents substituting a second detail record.
Editing requires resetting to draft, which re-routes the approval.

Tests: field swap, direct selected_field_name write, value swap, and detail
substitution are all rejected post-submit; draft edits still allowed.
spp_change_request_v2 307/307; spp_cr_type_assign_program 19/19,
spp_farmer_registry_cr 95/95 (custom detail types' created_* writes unaffected).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces security and integrity constraints to freeze change requests and their proposed details once they leave the draft or revision state, preventing users from modifying approved changes before they are applied. It also updates the field mapping strategy to apply and preview only the selected field for dynamic-approval change requests, backed by comprehensive unit tests. The review feedback correctly identifies potential false-positive lockouts in both change_request.py and change_request_detail_base.py due to direct comparisons of unset fields (where None is compared to False) and Odoo recordsets, which should be normalized to prevent unexpected UserError exceptions.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread spp_change_request_v2/models/change_request.py Outdated
Comment thread spp_change_request_v2/models/change_request_detail_base.py Outdated
@codecov

codecov Bot commented Jul 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.00000% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.34%. Comparing base (02de7ba) to head (45c8063).
⚠️ Report is 26 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_change_request_v2/models/change_request.py 93.75% 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 ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #264      +/-   ##
==========================================
+ Coverage   75.30%   75.34%   +0.04%     
==========================================
  Files        1095     1097       +2     
  Lines       64991    65474     +483     
==========================================
+ Hits        48939    49332     +393     
- Misses      16052    16142      +90     
Flag Coverage Δ
spp_api_v2_change_request 66.41% <ø> (ø)
spp_base_common 90.26% <ø> (ø)
spp_change_request_v2 75.70% <94.00%> (+0.24%) ⬆️
spp_cr_type_assign_program 91.17% <ø> (ø)
spp_cr_types_advanced 0.00% <ø> (ø)
spp_cr_types_base 0.00% <ø> (ø)
spp_dci_demo 69.23% <ø> (ø)
spp_farmer_registry_cr 61.15% <ø> (+0.05%) ⬆️
spp_farmer_registry_demo 60.97% <ø> (+8.23%) ⬆️
spp_programs 65.27% <ø> (ø)
spp_registry 86.83% <ø> (ø)
spp_security 66.66% <ø> (ø)
spp_starter_disability_registry 0.00% <ø> (?)
spp_starter_farmer_registry 0.00% <ø> (ø)
spp_starter_social_registry 0.00% <ø> (ø)
spp_starter_sp_mis 81.25% <ø> (ø)
spp_studio_change_requests 84.67% <ø> (ø)

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

Files with missing lines Coverage Δ
spp_change_request_v2/__manifest__.py 0.00% <ø> (ø)
spp_change_request_v2/models/change_request.py 84.57% <93.75%> (+0.21%) ⬆️
...ge_request_v2/models/change_request_detail_base.py 73.75% <95.65%> (+4.26%) ⬆️
spp_change_request_v2/strategies/field_mapping.py 69.41% <90.90%> (+1.46%) ⬆️

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

…ckout

Address Gemini review on PR #264: the post-submit freeze guards compared the
write payload to the stored value directly. Odoo stores unset fields as False,
but a JSON-RPC/integration payload may send None for the same field, and a
Many2one may be written as a recordset — so an idempotent re-save (None vs
False, or recordset vs the id-normalized current) was wrongly treated as a
change and locked out. Normalize both sides (recordset -> id, None -> False) in
both the CR and detail guards via a shared _normalize_frozen_value helper. Real
changes still differ and are still blocked.

Test: writing None to an already-unset protected field post-submit no longer
raises. spp_change_request_v2 308/308.
Record the dynamic-approval single-field apply + post-submit freeze hardening
in the version and HISTORY changelog.
@gonzalesedwin1123 gonzalesedwin1123 force-pushed the security-cr-dynamic-approval-field branch from 1be95e6 to 45c8063 Compare July 2, 2026 14:39
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.

1 participant