Skip to content

fix(spp_alerts): evaluate alert rules as their owner, not the elevated cron - #364

Draft
gonzalesedwin1123 wants to merge 6 commits into
19.0from
security-alert-rule-safe-eval
Draft

fix(spp_alerts): evaluate alert rules as their owner, not the elevated cron#364
gonzalesedwin1123 wants to merge 6 commits into
19.0from
security-alert-rule-safe-eval

Conversation

@gonzalesedwin1123

@gonzalesedwin1123 gonzalesedwin1123 commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Alert rules are evaluated by a scheduled cron running with an elevated identity (ir.cron's default user is the installer/superuser). The monitored search (spp.alert.rule._evaluate_ruleModel.search(domain)) ran with that identity, so record rules were bypassed for whatever a rule targeted.

group_alerts_manager — a delegated, non-base.group_system role — has full CRUD on spp.alert.rule, rules can target any model with any domain, and no record rule confines managers to their own rules. So a non-admin Alerts Manager could author, or repoint an admin-authored rule, over e.g. res.partner; the elevated cron evaluated it past the record rules that scope their visibility; and the resulting alerts (readable by all alert managers) leaked record values + names across the boundary, one rule at a time.

The reporter's primary vector — domain_filter safe_eval'd with a user recordset in context (ORM-as-superuser RCE) — was already remediated by commit 9cef192e (present on 19.0 and in releases v19.0.2.0.0/2026.07); the eval context is now {datetime, dateutil, time, uid} where uid is an int. This PR closes the remaining record-rule-bypass / data-leak half.

Fix

Evaluate each rule's monitored search as the user who configured what the rule targets, tracked by a new system-managed eval_as_user_id:

  • Defaults to the creator; stripped from client-supplied create/write values (not forgeable).
  • Re-bound to the editor in write() whenever a targeting field changes (model_id, domain_filter, monitored_field_id, date_field_id, rule_type) — so repointing an admin-authored rule de-escalates it to the editor. Editing a non-targeting field (name, priority, …) leaves ownership untouched.
  • _evaluate_rule runs the search via with_user((eval_as_user_id or create_uid).id), so record rules always bound a rule to whoever last defined its targeting, regardless of the elevated cron. An admin-authored rule keeps its wider scope.

Keying evaluation on create_uid alone would have closed the create vector but not the repoint an existing higher-privileged rule vector (managers have model-wide write) — hence the re-bind-on-targeting-change design.

Migration: migrations/19.0.2.0.1/post-migration.py backfills eval_as_user_id = create_uid for existing rules (module is released).

Tests (spp_alerts/tests/test_rule_evaluation_access.py, TDD)

  • test_elevated_eval_does_not_surface_owner_hidden_records / test_cron_path_respects_owner_record_rulesrepro: elevated eval no longer surfaces records the owner can't see, via the direct and cron entry points.
  • test_manager_repointing_owner_rule_de_escalates — a manager repointing an unrestricted-owned rule re-binds to the manager; the hidden record is not leaked.
  • test_manager_editing_nontargeting_field_preserves_owner_scope — editing a non-targeting field preserves the owner's scope.
  • test_eval_as_user_id_not_client_writable — cannot be forged via create or write.
  • test_manager_cannot_see_hidden_partner (sanity), test_unrestricted_owner_rule_still_spans_all_records (preserved system-wide scope).

Full module suite green: 111 tests, 0 failed.

The alert-rule evaluation cron runs with an elevated identity (ir.cron's
default user is the installer/superuser). The monitored search ran with that
identity, so a rule authored by a non-system-admin group_alerts_manager could
surface records the author is not allowed to see; the resulting alerts, readable
by all alert managers, leaked data across the record-rule boundary.

Evaluate each rule's monitored search as the rule's owner (create_uid) via
with_user(), so record rules are enforced against whoever configured the rule
regardless of who (or what cron) triggers evaluation. An admin-authored rule
keeps its wider scope.

Adds tests proving the elevated cron no longer surfaces owner-hidden records,
and that an unrestricted owner's rule still spans all records.
@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.

Comment thread spp_alerts/models/alert_rule.py Fixed
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 71.06%. Comparing base (1caf794) to head (bef2833).
⚠️ Report is 3 commits behind head on 19.0.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #364      +/-   ##
==========================================
- Coverage   74.28%   71.06%   -3.22%     
==========================================
  Files         372      134     -238     
  Lines       25385    11516   -13869     
==========================================
- Hits        18857     8184   -10673     
+ Misses       6528     3332    -3196     
Flag Coverage Δ
spp_alerts 97.35% <100.00%> (?)
spp_analytics ?
spp_api_v2_cycles ?
spp_api_v2_entitlements ?
spp_api_v2_gis ?
spp_api_v2_programs ?
spp_api_v2_simulation ?
spp_audit_programs ?
spp_base_common 91.07% <ø> (+0.80%) ⬆️
spp_case_demo ?
spp_case_entitlements ?
spp_case_programs ?
spp_cr_type_assign_program ?
spp_dci_compliance ?
spp_dci_demo ?
spp_dci_server_social ?
spp_demo ?
spp_demo_phl_luzon ?
spp_drims 80.86% <ø> (+0.03%) ⬆️
spp_drims_sl ?
spp_drims_sl_demo 68.57% <ø> (+0.24%) ⬆️
spp_farmer_registry_demo ?
spp_gis_report ?
spp_grm_demo ?
spp_indicator ?
spp_indicator_studio ?
spp_metric_service ?
spp_mis_demo_v2 ?
spp_programs 65.27% <ø> (+<0.01%) ⬆️
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_alerts/models/alert_rule.py 96.15% <100.00%> (ø)

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

create_uid is an ORM-readonly system field (not user input), so switching
the monitored search to it is safe despite odoo-with-user-unvalidated. Also
regenerate README.rst/index.html from the HISTORY fragment (CI oca-gen).
Keying evaluation solely on create_uid closed the create vector but not the
edit vector: group_alerts_manager has model-wide write on spp.alert.rule and
no record rule confines managers to their own rules, so a non-admin manager
could repoint an admin-authored rule (change model/domain/monitored field) and
have it still evaluate as the admin create_uid — re-opening the leak.

Add a system-managed eval_as_user_id (default create_uid) that is re-bound to
the editor whenever a targeting field (model_id, domain_filter,
monitored_field_id, date_field_id, rule_type) changes, and never client-writable
(stripped from create/write vals). Evaluate the monitored search as
eval_as_user_id so record rules always bound a rule to whoever last defined its
targeting. Ship a migration backfilling eval_as_user_id = create_uid for
existing rules.

Adds tests: manager repointing an unrestricted-owned rule de-escalates to the
manager; editing a non-targeting field preserves owner scope; eval_as_user_id
cannot be forged via create or write.
Addresses two-reviewer adversarial staff review of the A2 fix:

- HIGH (forge via context): create() force-sets eval_as_user_id instead of
  popping it, so the field is always present and Odoo default_get never honours a
  client default_eval_as_user_id context key (also covers copy()).
- HIGH (migration no-op): drop the Python default (which made _init_column
  pre-fill existing rows with the upgrade user before the migration) and make the
  backfill unconditional (SET eval_as_user_id = create_uid) so it is authoritative.
- MED: expand _EVAL_TARGETING_FIELDS with comparison, threshold_value,
  days_before, active — fields that change what a rule surfaces without touching
  model/domain.
- MED: fail closed — if no eval user resolves, skip the rule instead of searching
  with the elevated cron identity.
- MED: evaluate in the owner's own company scope, not the triggering cron's
  default company, so multi-company record rules apply as they would for that user.
- Low: write() copies vals (no caller mutation); comment that eval identity is the
  acting user (preserved under sudo).

Adds tests: context-default forge (create + copy), threshold/active rebind,
fail-closed, owner-company scoping, and the shipped migration's authoritative
backfill. 118 tests, 0 failed.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Staff review (two independent adversarial reviewers) — NEEDS-CHANGES, all fixed

A security-completeness reviewer (trying to break the fix) and a regression/correctness reviewer (Odoo 19 + upgrade behavior) reviewed the diff independently. Both returned NEEDS-CHANGES; each found one HIGH bug the other missed. All findings fixed in b156f3dc; suite now 118 tests, 0 failed, CI 22/22 green.

HIGH — evaluation identity forgeable via context default. create() popped eval_as_user_id, leaving it missing → Odoo default_get then honoured a client-supplied default_eval_as_user_id context key (and copy()). A manager could do with_context(default_eval_as_user_id=1).create({...}) and store the rule as superuser. Fix: create() now force-sets the field (dict(vals, eval_as_user_id=self.env.uid)) so it is always present and default_get is never consulted.

HIGH — migration was a no-op on real upgrades. The field carried a Python default, so Odoo's _init_column pre-filled existing rows with the upgrade user before post-migration ran; the WHERE eval_as_user_id IS NULL backfill then matched nothing, binding every pre-existing rule to one elevated identity. Fix: dropped the Python default (identity is set only in create()) and made the migration unconditional (SET eval_as_user_id = create_uid).

MEDIUM:

  • _EVAL_TARGETING_FIELDS extended with comparison, threshold_value, days_before, active — they change which records/values a rule surfaces without touching model/domain, so they must re-bind too.
  • Fail-closed: if no configurer resolves, the rule is skipped (logged) instead of falling back to the elevated cron identity.
  • Multi-company: the search now runs in the configurer's own company scope (allowed_company_ids = eval_user.company_ids), not the triggering cron's default company.

Low: write() copies vals (no caller-dict mutation); the identity is self.env.uid (the acting user, preserved under sudo()) — only an explicit with_user(<elevated>) write re-widens, documented in a comment.

New tests cover every finding: context-default forge (create + copy), threshold/re-activation rebind, fail-closed, owner-company scoping, and the shipped migrate() authoritatively overwriting a wrongly pre-filled identity.

Both reviewers confirmed clean: the read path (no re-elevation between search and alert values), the nosemgrep suppression, no downstream _inherit/override of spp.alert.rule, and version/HISTORY/README hygiene.

@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

Follow-up filed: #374group_alerts_manager has model-wide write/unlink on spp.alert.rule with no owner-scoping record rule, so a manager can edit/disable/delete another user's (incl. an admin's) rule. After this PR that is an integrity/availability concern only (evaluation de-escalates on edit), not a data leak — out of scope here, needs a delegation-model decision.

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