Skip to content

security(grm): evaluate rules as their owner, scope portal tickets, guard entry points (#379, #380, #381) - #415

Open
gonzalesedwin1123 wants to merge 5 commits into
19.0from
security/grm-rule-eval-cluster
Open

security(grm): evaluate rules as their owner, scope portal tickets, guard entry points (#379, #380, #381)#415
gonzalesedwin1123 wants to merge 5 commits into
19.0from
security/grm-rule-eval-cluster

Conversation

@gonzalesedwin1123

Copy link
Copy Markdown
Member

Fixes the GRM security cluster surfaced during the PR #266 staff review and confirmed by the PR #399 review verification. Three interlocking issues, one PR because they share a trust chain: #380 gives portal RPC reach to tickets → #381 gives reach to the rule engine → #379 means the engine acts as superuser.

Closes #379, #380, #381.

What's in it

Issue Severity Fix Module (version)
#379 HIGH GRM routing/escalation rules evaluate with their creator's identity, not the superuser cron spp_grm_cel 19.0.2.0.1 → 2.0.2 + migration
#380 HIGH Portal users scoped to their own grievance tickets (record rule + read-only ACL) spp_grm 19.0.2.0.1 → 2.0.2
#381 Medium Rule-engine entry points made @api.private (not RPC-callable) spp_grm_cel

#379 — owner-identity evaluation

The hourly check_escalations cron ran as superuser with record rules bypassed. A GRM officer (who can author rules) could create one always-match escalation rule pointing at themselves; within the hour the cron reassigned every open ticket in the database to them. Same elevated-evaluation shape as spp_alerts #364.

Fix (mirrors the #364 owner-identity pattern):

  • New system-managed eval_as_user_id on both rule models — no Python default (a default would let _init_column backfill the upgrade user and let a client forge it via default_eval_as_user_id); forced to the creator in create(), and re-bound to the editor only when a rule's targeting/action fields change in write().
  • Operational toggles (sequence, active) are deliberately excluded from the re-bind set: archiving/reordering an officer's rule must not silently transfer ownership to the manager doing that routine cleanup (a confused-deputy escalation caught in review).
  • apply_routing / apply_escalations evaluate and apply each rule with_user(owner); a ticket the owner can't read/write is skipped, never applied elevated. So an officer's rule can only ever act within the officer's own record-rule scope. The cron and the sudo'd SLA path inherit this automatically (the identity comes from the rule, not the caller).
  • A migration backfills eval_as_user_id from create_uid for pre-existing rules.

#380 — portal ticket isolation

spp.grm.ticket granted base.group_portal read/write/create with no ir.rule targeting portal, so any authenticated portal user could read and rewrite every grievance in the system over RPC (the controller's partner_id scoping is presentation-only).

  • New portal record rule: partner_id == user.partner_id (own tickets only).
  • Portal ACL row reduced to read-only — submission is handled by the sudo'd portal controller, which needs no direct model write/create.

#381 — entry-point guards

apply_routing, apply_escalations, apply_escalation, check_escalations are now @api.private — rejected for call_kw RPC dispatch. The cron (server-side model.check_escalations()), the SLA-breach path, and ticket create/stage-write are all in-process Python calls and unaffected.

Also (folded in from the #399 review)

  • Atomic UPDATE for match_count/escalation_count (drops the sudo() read-modify-write; no lost updates under concurrent cron/UI escalation).
  • CEL validation now reports any parser error as ValidationError (was SyntaxError-only).
  • Dropped the portal/internal-user read rows on both rule models — with owner-identity evaluation the acting user never reads the rules, so those rows only exposed the routing/escalation map to enumeration.

🔴 Release notes — behavior changes

  1. GRM automation rules now evaluate with their creator's identity. A rule can only route/escalate within that user's ticket scope. Officer-authored broad rules will scope down; cross-team rules must be owned by a manager/admin. In particular, officer-authored routing rules no longer apply to brand-new unassigned tickets (no officer can see them yet) — route those with manager/admin-owned rules. Existing rules are attributed to their original creator by the migration; review any rule whose creator's permissions have changed since it was authored.
  2. Portal users can no longer read or modify other users' grievance tickets over RPC. No portal-UI change.
  3. The three rule-engine methods are no longer callable over RPC.

Verification

  • TDD: red tests first (officer seizes all tickets; portal reads others' tickets; RPC dispatch succeeds) → green after fix.
  • spp_grm_cel 43 tests, spp_grm 30 tests — 0 failed, 0 errors.
  • Reviewed twice before push: openspp2-code-reviewer (conventions/principles) + an adversarial pass against Odoo 19 core. Both confirmed the two HIGH holes closed and the owner-identity/guard mechanisms sound; the one Important finding (the active/sequence confused-deputy) is fixed with a regression test. Lint clean (ruff, pylint-odoo, bandit, semgrep).
  • README.rst / index.html regen deferred to CI's pinned generator (will apply its printed diff).

Follow-ups (not in scope)

  • trigger_after_hours is not enforced at apply time (_check_time_trigger is unused) — pre-existing; file to wire it in or remove the field.
  • ondelete="restrict" on eval_as_user_id blocks deleting a user who owns rules — consider reassign-on-archive if it bites.
  • Legacy (4, id) tuple at grm_escalation_rule.py — Odoo 19 Command.link nit, pre-existing.

…ts (#379, #381)

- eval_as_user_id (system-managed, no default) on both rule models; forced to
  the creator in create() and re-bound to the editor when targeting changes in
  write(), so it cannot be forged via context or direct write. Re-bind excludes
  operational toggles (sequence, active) so a manager archiving/reordering an
  officer's rule cannot silently transfer ownership to the manager's scope.
- apply_routing/apply_escalations evaluate and apply each rule
  with_user(owner): an officer's always-match rule can no longer ride the
  superuser cron to seize tickets outside the officer's record-rule scope
  (#379). Owner-unreadable/unwritable tickets are skipped, not applied elevated.
- @api.private on apply_routing, apply_escalations, apply_escalation,
  check_escalations: no longer RPC-dispatchable (#381). Cron/SLA callers are
  in-process and unaffected.
- Drop the portal and internal-user read rows on both rule models — owner
  identity removes the need, closing the enumeration surface (#380).
- Atomic UPDATE for match_count/escalation_count (no sudo, no lost updates).
- CEL validation reports any parser error as ValidationError.
- Migration backfills eval_as_user_id from create_uid.
spp.grm.ticket granted base.group_portal read/write/create with no ir.rule
targeting portal, so any portal user could read and rewrite every grievance
in the system over RPC (the controller's partner scoping is presentation-only).

- New portal record rule: partner_id == user.partner_id (own tickets only).
- Portal ACL row reduced to read-only (1,0,0,0); submission is handled by the
  sudo'd portal controller, which needs no direct model write/create.
- New tests/test_portal_ticket_acl.py: portal cannot read/search/write/create
  others' tickets; can read own.
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.68182% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.94%. Comparing base (0820667) to head (4f97977).
⚠️ Report is 72 commits behind head on 19.0.

Files with missing lines Patch % Lines
spp_grm_cel/models/grm_routing_rule.py 76.92% 9 Missing ⚠️
spp_grm_cel/models/grm_escalation_rule.py 83.67% 8 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             19.0     #415      +/-   ##
==========================================
+ Coverage   72.24%   72.94%   +0.69%     
==========================================
  Files         419      453      +34     
  Lines       29813    31257    +1444     
==========================================
+ Hits        21539    22800    +1261     
- Misses       8274     8457     +183     
Flag Coverage Δ
spp_base_common 91.07% <ø> (ø)
spp_grm 63.34% <ø> (?)
spp_grm_case_link 100.00% <ø> (?)
spp_grm_cel 77.21% <80.68%> (-0.02%) ⬇️
spp_grm_demo 81.43% <ø> (?)
spp_grm_programs 92.13% <ø> (?)
spp_grm_registry 100.00% <ø> (?)
spp_programs 67.56% <ø> (+2.28%) ⬆️
spp_registry 87.79% <ø> (+0.64%) ⬆️
spp_security 69.56% <ø> (ø)

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

Files with missing lines Coverage Δ
spp_grm_cel/models/grm_escalation_rule.py 77.77% <83.67%> (+1.01%) ⬆️
spp_grm_cel/models/grm_routing_rule.py 80.18% <76.92%> (-3.37%) ⬇️

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

…#415)

README.rst / index.html for spp_grm and spp_grm_cel applied verbatim from the
pre-commit CI run's printed diff (local RST regen is not byte-reproducible).
test_rule_owner_identity.py reformatted per ruff-format.
if not owner:
continue
# nosemgrep: semgrep.odoo-with-user-unvalidated -- owner is system-set in create()/write(), not client input
rule_as_owner = rule.with_user(owner.id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

with_user(SUPERUSER_ID) re-opens the exact bypass this PR closes.

Odoo 19 Environment.__new__ (odoo/orm/environments.py:66) forces su = True whenever uid == SUPERUSER_ID, and with_user's own docstring says so: "in non-superuser mode, unless user is the superuser (by convention, the superuser is always in superuser mode)".

So for any rule whose eval_as_user_id (or create_uid fallback) is uid 1, rule.with_user(owner.id) / ticket.with_user(owner.id) produce a su=True environment: ACLs and record rules are fully bypassed and the rule applies to every ticket in the DB — the pre-fix #379 behaviour, silently.

That is not a hypothetical: create() stores self.env.uid, which is SUPERUSER_ID for anything created during module data load, from odoo shell, from an import/upgrade script, or from a TransactionCase — and the new migration mints exactly these owners by copying create_uid. It is also why the pre-existing test_routing_rules.py / test_escalation_rules.py still pass unchanged: their rules are admin-created, so owner-identity never actually constrains them.

At minimum, refuse (or loudly log) a uid‑1 owner rather than letting it silently mean "unrestricted":

owner = rule.eval_as_user_id or rule.create_uid
if not owner or owner.id == SUPERUSER_ID:
    _logger.warning("Rule %s has no bounded owner identity; skipping", rule.name)
    continue

Same applies at grm_routing_rule.py:303.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified against core — the mechanism is exactly as you say (environments.py:66 forces su=True for uid 1, and with_user can't override it), and the migration can indeed carry uid-1 owners forward from shell/script-created rules. Two corrections to the narrative, though: no XML/demo data anywhere creates these rules, and an RPC caller always mints their own uid, so the #379 escalation path itself stays closed — the exposure is confined to rules authored from already-privileged contexts. Also a nuance in the test claim: base.user_admin is uid 2 (record-rule-bounded, just broad), but the legacy suites use the bare test env, which IS uid 1 — so they were exercising the bypass path outright. Addressed as follows rather than skipping (a silent skip would make legitimately system-provisioned rules stop firing on upgrade with no signal): both apply loops now log a warning when a rule's owner is the superuser, the migration calls out uid-1 rows by name, and both legacy suites now author their rules as a real GRM manager so the whole suite runs through the scoped path.

@@ -3,7 +3,7 @@ access_spp_grm_ticket_viewer,GRM Ticket Viewer Access,model_spp_grm_ticket,group
access_spp_grm_ticket_officer,GRM Ticket Officer Access,model_spp_grm_ticket,group_grm_officer,1,1,1,0
access_spp_grm_ticket_manager,GRM Ticket Manager Access,model_spp_grm_ticket,group_grm_manager,1,1,1,1
access_spp_grm_ticket_base_user,GRM Ticket Base User Access,model_spp_grm_ticket,base.group_user,1,0,0,0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The same enumeration hole #380 fixes for portal is still wide open for every internal user.

base.group_user keeps unscoped read on spp.grm.ticket, and security/rules.xml has no ir.rule targeting base.group_user. Because Odoo ORs the record rules of the groups a user belongs to, a plain internal user (Registry Viewer, Farm User, Program Viewer — none of them in a group_grm_* group) matches no rule on this model and therefore reads every grievance in the database: complainant identity, description, contact. Only users who do hold a GRM group get scoped down by rule_spp_grm_ticket_viewer / _officer.

This PR drops the base.group_user read rows on both rule models for precisely this reason ("only exposed the routing/escalation map to enumeration"), so leaving the far more sensitive ticket model unscoped is inconsistent. Either add a base.group_user record rule or drop this ACL row.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, including the core semantics (no matching group rule and no global rule ⇒ unrestricted), and agreed it's the same class of hole as #380 — thank you. Two wrinkles argue for doing it as an immediate follow-up rather than in this PR: dropping the ACL row would break res_partner._compute_grm_ticket_count (an unsudo'd search that runs for every internal user opening a partner form), so the right shape is an added base.group_user record rule, not a removal; and scoping every internal user's GRM visibility is a behavior change for non-GRM staff that deserves its own release-note headline and its own review rather than riding along unannounced here. Filed as #486 with the proposed rule domain; happy to have it land right behind this PR.

Comment thread spp_grm/security/rules.xml Outdated
<field name="domain_force">[('partner_id', '=', user.partner_id.id)]</field>
<field name="groups" eval="[Command.link(ref('base.group_portal'))]" />
<field name="perm_read" eval="True" />
<field name="perm_write" eval="False" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

perm_write/perm_create/perm_unlink = False removes the defence-in-depth the comment claims to add.

An ir.rule with only perm_read constrains reads only. The comment above says "the perms here mirror the ACL as defense in depth" — but mirroring is the opposite of defence in depth here: the ACL row becomes the single thing preventing portal writes. The moment any module (or a future edit to this very CSV) re-grants base.group_portal write on spp.grm.ticket, portal users can write every ticket again, because this rule will not apply to the write.

Odoo's own portal rules (helpdesk, project, sale) set all four perms true for this reason. Suggest:

Suggested change
<field name="perm_write" eval="False" />
<field name="perm_write" eval="True" />
<field name="perm_create" eval="True" />
<field name="perm_unlink" eval="True" />

(costs nothing today — portal has no write/create/unlink ACL — and keeps the scoping if that ever changes)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken — all four perms are now enabled, and the "mirror the ACL" comment is rewritten to say what the flags actually do. One correction for the record: core does not consistently set all four on portal rules — project_task_rule_portal (project_security.xml) is read-only exactly like this rule was, while sale mixes forms. The change is still free defense-in-depth, which is why we took it. Separate product question your comment surfaced: our domain is partner_id == user.partner_id, where core portal patterns often use child_of commercial_partner_id — whether a household member should see the household's grievance is tracked in #487.

@@ -2,10 +2,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_spp_grm_routing_rule_viewer,GRM Routing Rule Viewer Access,model_spp_grm_routing_rule,spp_grm.group_grm_viewer,1,0,0,0
access_spp_grm_routing_rule_officer,GRM Routing Rule Officer Access,model_spp_grm_routing_rule,spp_grm.group_grm_officer,1,1,1,0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Officers can still steer manager-owned rules — the active/sequence exclusion is one-sided.

spp_grm_cel/security/ ships no rules.xml, so there is no ir.rule on either rule model: this officer row (1,1,1,0) lets any GRM officer write any rule, including ones owned by a manager.

The PR deliberately excludes active and sequence from _EVAL_TARGETING_FIELDS so that a manager tidying an officer's rule doesn't inherit ownership. The reverse case is not handled: an officer can un-archive a dormant manager-owned rule, or reorder manager-owned routing rules so a different one wins — and those rules then execute with the manager's org-wide ticket scope, with no ownership re-bind and no audit signal. That is the confused-deputy shape the exclusion was meant to prevent, just pointed the other way.

The deeper fix is a record rule on both models scoping non-managers to rules they own, e.g. [('eval_as_user_id', '=', user.id)] for group_grm_officer (write/unlink), leaving managers global. That makes the active/sequence exclusion safe in both directions instead of only one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, and it's arguably worse than stated: the disable direction (an officer archiving the manager's SLA-breach escalation rule — org-wide escalations silently off, no ownership change, no trace) is the likelier real harm, and the rule-config menus are already manager-only, so the officer rows are RPC-only reach. The fix needs care though: group_grm_manager implies group_grm_officer, so an officer-scoped write rule alone would also cage managers — it takes BOTH a manager [(1,'=',1)] rule and an officer [('eval_as_user_id','=',user.id)] write/unlink rule. That design interacts with the ownership machinery this PR introduces, so it's filed as #488 with the two-rule shape spelled out rather than rushed in here.

if matched:
try:
rule_as_owner.apply_escalation(ticket_as_owner)
except AccessError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This except AccessError can fire after the escalation has already been applied — the comment's promise is not kept.

apply_escalation (line 349) performs, in order: ticket.write(vals)ticket.write({"escalation_rule_ids": ...}) → optional notification/case → the raw UPDATE ... escalation_count + 1ticket.message_post(...). Only the first step is the one the comment is guarding against.

If an AccessError is raised by anything from the second step onward — message_post, or flush_recordset(["escalation_count"]) when the owner has read-only rule access — this handler swallows it and continues without setting applied = True. Net result: the ticket is flagged escalated, reassigned and the counter is incremented in the DB, while apply_escalations returns False and check_escalations under-reports its escalated count. The comment ("skip rather than apply with elevated rights") is then actively misleading: it was already applied.

Either pre-check writability before calling apply_escalation (see the _filtered_access suggestion on the routing side) so the except only ever covers a no-op, or set applied = True / re-raise once the ticket write has landed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed, and it was worse than the comment promised — thank you, this was the best catch of the review. The reachable path is message_post: mail.message create requires write access on the document, and an officer's rule that escalates a ticket to another team removes the officer's own write access mid-apply, so the final chatter post raised and the handler swallowed a half-applied escalation (reassigned, counter bumped, message lost, applied False). Fixed with a savepoint around apply_escalation — a post-write denial now rolls the whole escalation back to a clean un-escalated state — plus the chatter post moved ahead of the external notification (so a rolled-back escalation can never have already sent an email), an info log on the skip, and a regression test that pins the rollback end-to-end (no state change, no counter, no message). Note the resulting semantics, called out in HISTORY: an officer rule that reassigns a ticket out of the officer's own scope now fails closed entirely; cross-team escalation rules must be owned by a manager, consistent with the release notes' owner-scope rule.

for rule in rules:
if rule.evaluate(ticket):
# Apply the rule's actions
owner = rule.eval_as_user_id or rule.create_uid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing checks that the owner is still an active user.

eval_as_user_id carries ondelete="restrict", which blocks deletion — but the normal offboarding action in Odoo is archive, and with_user() does not check active. An officer who has left keeps granting their full record-rule scope to every rule they authored, indefinitely and invisibly, because the identity is resolved from the stored m2o rather than from a live principal.

Worth either skipping (with a warning) when not owner.active, or documenting this explicitly next to the ondelete="restrict" follow-up already noted in the PR description. There is no test covering an archived owner.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed — with_user never checks active, and archiving removes neither groups nor team membership, so an offboarded officer's rules keep their frozen scope indefinitely. It's stale authority rather than a widening, but for a grievance system that's still an audit finding. Filed as #489 (surface archived owners, consider warn/deactivate). One thing your comment surfaced that we've documented in HISTORY now: ondelete="restrict" means a user who owns rules can no longer be deleted at all — archive is the supported offboarding path.

if rule.evaluate(ticket):
rule.apply_escalation(ticket)
owner = rule.eval_as_user_id or rule.create_uid
if not owner:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A rule that loses its owner silently stops firing, with zero diagnostics.

create_uid is a standard m2o whose FK is ON DELETE SET NULL, so a row can legitimately end up with both eval_as_user_id IS NULL and create_uid IS NULL. This continue then disables the rule permanently — no log line, no UI signal, and the form still renders "Evaluated As" as empty rather than "never runs".

Add a _logger.warning("Escalation rule %s has no evaluation identity; skipping", rule.name) before the continue so this is diagnosable in production. Same at grm_routing_rule.py:300.

Separately: because of the or rule.create_uid fallback, the new post-migration.py backfill is behaviourally redundant — either drop the fallback and rely on the migration, or drop the migration. Keeping both means the UI shows an empty owner while the engine quietly uses a different one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The silent skip is fixed — both models now log a warning naming the rule when it has no evaluation identity. On the redundancy claim, partially disagree: the fallback and the migration overlap only on the evaluation path. The migration additionally makes ownership visible in the form, engages ondelete="restrict", and leaves an audit record — and dropping the fallback would make everything depend on the migration having run. The fair version of your point (the fallback masks a skipped migration) is noted in #489 alongside the archived-owner work. Also, for completeness: we re-checked the write()/create() path you were circling — eval_as_user_id is popped from client writes and force-set on create, so it can be neither set nor cleared directly; that's pinned by two existing tests.

ticket_as_owner = ticket.with_user(owner.id)
try:
matched = rule_as_owner.evaluate(ticket_as_owner)
except AccessError:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Exception-driven access control: this except AccessError catches far more than "the owner cannot see this ticket".

evaluate()_build_evaluation_context(ticket) touches ticket.category_id, channel_id, stage_id, partner_id, team_id, user_id (and on the escalation side sla_status, days_open). An AccessError from any of those related models — or from _check_time_trigger, or from a future callee — is indistinguishable here from "out of scope", and is silently reclassified as "the rule doesn't match". Routing/escalation then stops working for reasons that produce no log line at all.

Odoo 19 has a first-class helper for the check you actually want (odoo/orm/models.py:4121):

ticket_as_owner = ticket.with_user(owner.id)._filtered_access("write")
if not ticket_as_owner:
    continue
matched = rule.with_user(owner.id).evaluate(ticket_as_owner)

That is one explicit access check instead of two try/except blocks, it cannot mask unrelated AccessErrors, and it removes the ordering hazard flagged on apply_escalation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Half taken, half rebutted. Taken: both except AccessError branches now log (debug on the read side, info on the write side), so a rule that never fires is diagnosable. Rebutted: the outer handler cannot swallow related-model AccessErrors — _build_evaluation_context reads only ticket-model fields (the escalation extras sla_status/days_open/is_escalated are stored columns), comodel records are browsed lazily, and CEL-evaluation errors are already caught and logged inside evaluate() — so an AccessError there really does mean "the owner cannot read this ticket". On _filtered_access: it exists as you describe, but it short-circuits under env.su (inheriting the uid-1 issue), it can't replace the try/except (post-write failures — see thread 5), and since the write-denial skip happens before the counter increment it wouldn't change any observable behavior. Happy to take it as a clarity refactor in the mixin follow-up (#491).

P.parse(rule.condition_cel)
# If parser not available, skip validation
except SyntaxError as e:
except Exception as e:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

except Exception is broader than the bug it fixes, and only 2 of ~6 copies of this pattern were fixed.

cel_parser.parse() raises SyntaxError, plus RecursionError (cel_parser.py:580) and IndexError on truncated token streams — that's the real gap. Catching bare Exception additionally converts genuine parser bugs (AttributeError, TypeError) into a user-facing "Invalid CEL expression", which hides them. (SyntaxError, RecursionError, IndexError, ValueError) states the intent precisely.

More importantly, this is the wrong altitude: the identical except SyntaxError-only validation lives in five other places that this PR leaves broken —

  • spp_programs/models/cel/entitlement_inkind_cel.py:182 and :328
  • spp_programs/wizard/create_program_wizard_cel.py:713 and :908
  • spp_studio/models/logic.py:339, spp_studio/wizard/variable_install_wizard.py:230

A validate(expr) helper in spp_cel_domain.services.cel_parser that owns the exception set would fix all of them once; two more hand-rolled copies here entrenches the drift. (Same code at grm_escalation_rule.py:207.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rebutting both halves, with one genuinely useful thing your comment led us to. (1) In an @api.constrains validator, except Exception is the fail-closed direction: any parser failure rejects the expression. Narrowing to a fixed tuple fails open into a traceback for whatever the parser throws next — and it does have unguarded raises beyond SyntaxError (MAX_RECURSION_DEPTH guards evaluate, not parse). We did add a debug log so a genuine parser bug is distinguishable from a bad expression. (2) The five listed sites don't share this bug: entitlement_inkind_cel.py:182 uses ast.parse, :328 and both wizard sites use compile() — for Python's own parser, SyntaxError-only is correct — and the two spp_studio sites are best-effort extractors with an intended regex fallback, not validators. The real defect at those coordinates is different and bigger: four of them validate expressions the product calls "CEL" against the Python grammar, so the wizard's "Formula syntax is valid" disagrees with what the actual CEL parser accepts. Filed as #490 — genuinely good outcome of this thread.

# a rule must not silently transfer ownership to the person doing that
# routine action (a manager cleaning up an officer's rule would otherwise
# re-bind it to the manager's broad scope).
_EVAL_TARGETING_FIELDS = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

~60 lines of owner-identity plumbing are duplicated verbatim across the two rule models.

eval_as_user_id + the "No Python default on purpose" comment + the _EVAL_TARGETING_FIELDS doc comment + create() + write() are byte-identical between grm_routing_rule.py:108-162 and grm_escalation_rule.py:142-195; only the tuple contents differ. The owner-resolution preamble in apply_routing/apply_escalations (owner lookup, two with_user calls, two nosemgrep annotations, the AccessError handling) is duplicated too.

An AbstractModel mixin — say spp.grm.rule.owner.mixin — holding the field, create, write and a _rule_owner_env(rule, ticket) helper, with each concrete model supplying only _EVAL_TARGETING_FIELDS, would halve this and guarantee the two copies can't drift. They already have: only the routing create() carries the docstring explaining why the key is set rather than popped.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed in direction, quibble on size: the truly verbatim region is ~30 lines (the field + create/write), not ~60 — _EVAL_TARGETING_FIELDS differs by design and the apply-loop preambles are embedded in differently-shaped loops. The drift you spotted was real and is fixed in this PR (both files now carry the identical full rationale in create/write docstrings). The mixin itself is filed as #491: with exactly two copies and an open question about where such a mixin should live so spp_alerts-style adopters can reach it, it's a refactor that shouldn't ride a security fix.

self.assertTrue(applied)
self.assertEqual(rule.escalation_count, before + 1)
self.assertTrue(ticket.is_escalated)
def test_portal_user_cannot_read_rules(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

test_rule_readonly_caller_escalation_increments_counter was deleted and its scenario is not re-covered.

AGENTS.md ("Tests"): "NEVER remove or weaken existing tests without explicit approval".

Inverting test_portal_user_can_read_rules into test_portal_user_cannot_read_rules is fine — the assertion genuinely flipped. But the deleted counter test asserted something orthogonal that nothing now covers: that a caller holding only read on the rule models still gets a fully applied escalation, counter included. The replacement (test_escalation_counter_increments_under_owner_identity) exercises a manager-owned rule via the superuser cron, i.e. the fully-privileged path in both dimensions.

Given that the raw-SQL counter increment replaced the sudo() write in the very same PR, this is exactly the regression that test existed to catch. Please re-add an equivalent under the new model (e.g. rule owned by a manager, apply_escalations invoked by a GRM viewer).

Also note the docstring rewrite attributes dropping the base-user read rows to #380 (a portal-ticket issue), and no test covers a plain base.group_user being denied read on the rule models.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Both concrete sub-claims taken: the #380 attribution was wrong in the test docstring AND in HISTORY (now corrected to "hardening alongside #379/#381" — #380 is the ticket-side portal issue), and base.group_user read denial on the rule models is now pinned by a new test. On the deleted counter test, rebutting: the scenario is not reproducible under the new model even in spirit — the caller's rights stopped mattering by design (everything after the rule search runs as the owner), and the regression it guarded (counter write blocked by caller rights) is structurally impossible now that the increment is raw SQL. The nearest live equivalent (a viewer triggering action_escalate) is noted for the follow-up test pass.

# Atomic increment: avoids lost updates under concurrent cron/UI
# escalation, and needs no sudo (raw SQL bypasses ACL). Invisible to
# spp_audit ORM write-hooks, which is acceptable for a stats counter.
self.flush_recordset(["escalation_count"])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Legacy (4, id) tuple in a function this PR edits.

Line 392, six lines above this hunk, is still:

ticket.write({"escalation_rule_ids": [(4, self.id)]})

AGENTS.md ("Views and XML"): "Always use Command.create() not (0, 0, {...}) tuples for relational writes" — Odoo 19. This should be Command.link(self.id) with Command added to the from odoo import ... line. The PR description files it as an out-of-scope follow-up, but apply_escalation is being modified here, so it costs one line now.

On the increment itself: the rationale comment says the atomic UPDATE prevents "lost updates". Odoo cursors run at REPEATABLE READ, where the previous read-modify-write would have raised a serialization failure and been retried, not silently lost. The change is still an improvement (one statement, no sudo), but the comment overstates what it fixes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Tuple fixed (Command.link). On the isolation-level point: you're right that cursors run REPEATABLE READ and the old read-modify-write would have raised a serialization failure rather than silently losing the update — but the conclusion doesn't follow, because Odoo only auto-retries at whole-dispatch granularity, and re-running check_escalations re-fires force_send notifications and case creation for every already-processed ticket. Avoiding the row conflict entirely is the point; the comment now says exactly that instead of "lost updates".

access_spp_grm_ticket_manager,GRM Ticket Manager Access,model_spp_grm_ticket,group_grm_manager,1,1,1,1
access_spp_grm_ticket_base_user,GRM Ticket Base User Access,model_spp_grm_ticket,base.group_user,1,0,0,0
access_spp_grm_ticket_portal_user,GRM Ticket Portal User Access,model_spp_grm_ticket,base.group_portal,1,1,1,0
access_spp_grm_ticket_portal_user,GRM Ticket Portal User Access,model_spp_grm_ticket,base.group_portal,1,0,0,0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

spp_grm/security/compliance.yaml now contradicts the code it declares.

That file is the module's declared access spec (python -m scripts.compliance.checker spp_grm, wired into .pre-commit-config.yaml:213). Two statements in it are now false and were not updated:

  • line 290: # - base.group_portal: Create/edit own tickets (for portal/self-service) — portal is read-only as of this row.
  • record_rules: does not declare the new rule_spp_grm_ticket_portal.

I ran the checker against this branch: 0 errors, 0 warnings — it validates declared entries against reality but does not detect entries that exist in code and are missing from the spec. So this drift is silent, and the next reader of compliance.yaml will get the pre-#380 picture. (rule_spp_grm_ticket_officer_create is already undeclared for the same reason — worth fixing both while here.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed: the portal comment now describes read-only access via the sudo'd controller, and both rule_spp_grm_ticket_portal and the pre-existing undeclared rule_spp_grm_ticket_officer_create are declared under record_rules. Good catch on the checker's blind direction.

Private: invoked server-side by ir.cron, never via RPC.
"""
# Find all open tickets
tickets = self.env["spp.grm.ticket"].search(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hourly cron is now O(open_tickets × rules) access checks, on top of a pre-existing N+1.

check_escalations loops every open ticket and calls apply_escalations, which re-runs self.search([("active", "=", True)], order="sequence, id") per ticket (line 544). This PR then adds, per (ticket × rule): an Environment construction for with_user, and an AccessError-driven access check on the ticket — each of which recomputes the owner's ir.rule domain and issues SQL. At 10k open tickets × 20 rules that is 200k access evaluations per hour.

Two cheap structural fixes:

  1. Hoist the rule search out of apply_escalations (pass rules in, or split an internal _apply_escalations(ticket, rules)).
  2. Group rules by eval_as_user_id and resolve the visible ticket set once per ownertickets.with_user(owner)._filtered_access("write") — instead of per (ticket, rule) pair. Distinct owners are typically a handful, so this collapses the access work to O(owners).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partially taken. The per-ticket rule re-search was real waste and is hoisted (searched once per cron pass, passed down). The rest is smaller than it looks: environments are interned, with_env preserves the prefetch set (so ticket reads batch per owner), and rule-domain checks run in memory over the prefetched cache — the per-pair cost is not 200k SQL access evaluations. The owner-grouped restructure as described would change semantics: routing is first-match-wins in global sequence, id order and escalation is last-writer-wins in that order, so iterating owner-by-owner can change which rule wins and the final ticket state. The safe version (precompute per-owner allowed-ticket sets, keep the original iteration order) plus batching and per-ticket error isolation (one bad ticket currently aborts the whole hourly run — found while verifying this) is filed as #492.

"""#381: the three rule-engine methods must be rejected for RPC dispatch."""
from odoo.service.model import call_kw

for model, method, args in [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two gaps in the new suite.

1. apply_escalation is not covered here. The changelog and README claim four guarded entry points (apply_routing, apply_escalations, apply_escalation, check_escalations); this loop tests three. The missing one is the record-level method that actually writes the ticket, so it is the one most worth pinning:

(ESCALATION, "apply_escalation", [rule.id, self.foreign_ticket.id]),

2. test_escalation_counter_increments_under_owner_identity (line 156) assumes exactly one open ticket exists database-wide. check_escalations() scans every is_closed = False ticket and the rule uses condition_cel: "" (always matches), so the counter increments once per open ticket. The before + 1 assertion holds today only because nothing else ships an open ticket; it breaks the moment demo data or another post_install fixture adds one. Assert against the ticket count, or call apply_escalations(self.foreign_ticket) directly instead of the whole-DB cron.

Also worth noting for test_officer_rule_cannot_seize_foreign_ticket: with condition_cel: "", evaluate() returns True without ever reading the ticket, so that test only proves the ticket write was denied — it would still pass if owner-identity evaluation were removed entirely. A non-empty condition referencing a ticket field would make it a real regression test for the evaluate path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

All three taken, with one wording correction. (a) apply_escalation added to the dispatch-guard loop — it was @api.private all along, but HISTORY advertises four guarded methods and the test now pins all four. (b) The counter test now targets one explicit ticket instead of the DB-wide cron scan; for the record nothing in the dependency closure ships an install-time ticket today, so it was latent rather than live, but agreed it was one fixture away from flaking. (c) A second seize test with a non-empty condition now pins the read-side bound (the empty-condition short-circuit meant only the write bound was covered). The strong form — "would pass even if owner-identity evaluation were removed entirely" — wasn't quite right (reverting to the superuser cron makes the original test fail loudly), but the blind spot was real and is closed.

…gging, scoped test surface

Review-response fixes (kneckinator, 15 threads; verification dossier in
internal docs):

- apply_escalation is now atomic: a savepoint rolls the whole escalation
  back when a post-write step is denied (e.g. an officer's rule reassigns
  the ticket out of the officer's own scope, then the chatter post fails),
  instead of persisting a half-applied escalation with its message lost.
  The chatter post moves ahead of the external notification so a rolled-
  back escalation can never have already sent an email. Regression test.
- Rules with no evaluation identity, superuser-owned rules (which evaluate
  with record rules bypassed), and owner-access skips are now logged; the
  migration calls out uid-1 backfills by name.
- The legacy routing/escalation suites author rules as a GRM manager
  instead of the superuser test env, so they exercise the owner-scoped
  path rather than the bypass; new tests pin base-user read denial on the
  rule models, the read-side (evaluate) access bound, apply_escalation's
  RPC guard, and the out-of-scope rollback.
- Portal ticket rule: all four perms enabled so the scoping holds if a
  future ACL change re-grants portal write; comment rewritten.
- compliance.yaml: portal access description corrected; declare
  rule_spp_grm_ticket_portal and rule_spp_grm_ticket_officer_create.
- Escalation cron searches the active-rule set once per pass, not once
  per open ticket.
- HISTORY: #380 misattribution on the rule-model row drop corrected
  (ticket-side portal scoping is #380; the row drop is #379/#381
  hardening); ondelete=restrict user-deletion consequence documented.
- Command.link for the escalation m2m; counter comments now describe the
  serialization-failure/retry rationale accurately; owner-identity
  create/write docstrings re-synced between the two rule models.
@gonzalesedwin1123

Copy link
Copy Markdown
Member Author

@kneckinator thanks for a genuinely strong review — every one of the 15 threads got an evidence-checked reply (we verified each claim against Odoo 19 core sources before responding).

Fixed in 1bbcdf6 (spp_grm_cel 46/46, spp_grm 30/30 tests green):

  • apply_escalation is now savepoint-atomic — your thread on the post-write AccessError was the best catch of the review; a denied post-write step now rolls the whole escalation back instead of persisting half-applied with its chatter message lost (regression test included, chatter post moved ahead of the external notification).
  • Superuser-owned, ownerless, and access-skipped rules are now logged; the migration calls out uid-1 backfills by name; both legacy suites author rules as a real manager instead of the su=True test env.
  • Portal ticket rule covers all four perms; compliance.yaml corrected + both undeclared rules declared; per-ticket rule re-search hoisted out of the cron loop; Command.link; the security(spp_grm): portal users have read/write/create on EVERY grievance ticket (no record rule targets portal) [Severity: High] #380 misattribution fixed in test + HISTORY; base-user read denial, the read-side seize bound, and apply_escalation's RPC guard are now pinned by tests.

Filed as follow-ups (with your threads referenced): #486 internal-user ticket read scoping, #487 portal household-visibility question, #488 officer write access to manager rules, #489 rule-ownership lifecycle, #490 Python-grammar "CEL" validation in spp_programs (found while verifying thread 9), #491 owner-identity mixin, #492 cron batching/error isolation.

Rebutted with receipts (details in the threads): the REPEATABLE READ point (dispatch-granularity retry blast radius), the related-model AccessError mechanism, the exception-narrowing on a fail-closed constraint, and the owner-grouped cron restructure (breaks first-match-wins ordering).

README regen for the HISTORY changes will be applied from CI's pinned generator diff as usual. Ready for re-review.

@kneckinator

Copy link
Copy Markdown
Contributor

Code review — 15 findings

Reviewed at high effort against security/grm-rule-eval-cluster (24 files, +953/−77).

Verified clean locally: ruff check / ruff format, semgrep --config .semgrep/ (the semgrep.odoo-with-user-unvalidated annotation form is correct — semgrep namespaces the config dir), and scripts.compliance.checker --all with 0 errors for spp_grm. @api.private confirmed present in Odoo 19 (odoo/orm/decorators.py:327) and enforced in get_public_method.

Correctness — high severity

1. spp_grm_cel/models/grm_escalation_rule.py:613 — the savepoint can corrupt an in-flight compute.
The new with self.env.cr.savepoint(): flushes on entry and calls cr.clear() on rollback, but apply_escalations is reachable from inside the stored sla_status compute (spp_grm/models/grm_ticket.py:542). A rollback wipes the whole transaction cache and every pending towrite, including the sla_status values the compute already assigned for the remaining records in self. A batch write that breaches SLA on 2+ tickets, where one rule owner lacks write access, yields Compute method failed to assign spp.grm.ticket.sla_status or silently lost writes. The comment at grm_ticket.py:543 ("to avoid triggering compute dependencies during the compute itself") shows the author of that call already knew flushing there is unsafe.

2. spp_grm_cel/security/ir.model.access.csv:5 — dropping the base.group_user read rows breaks rule evaluation.
The stated rationale ("with owner-identity evaluation the acting user never reads the rules") is not accurate: grm_escalation_rule.py:557 and grm_routing_rule.py:296 both run self.search([('active','=',True)]) in the caller's environment. The "Check Escalation" stat button (spp_grm_cel/views/grm_escalation_rule_views.xml:227) carries no groups= attribute, so a base.group_user internal user who reaches a ticket form (they hold unscoped read on spp.grm.ticket) clicks it → apply_escalations → AccessError on the rule search → swallowed by except Exception in spp_grm_cel/models/grm_ticket.py:97 → the button still returns the success notification "Escalation rules have been evaluated". Suggested fix at the right depth: sudo() the rule search, since owner identity already bounds every effect, rather than depending on acting-user ACL rows.

3. spp_grm/security/ir.model.access.csv:5 — the #380 hole is closed for portal but left open for internal users.
access_spp_grm_ticket_base_user grants base.group_user unscoped read on spp.grm.ticket with no matching ir.rule. spp_grm/security/rules.xml has rules for viewer, officer, supervisor, manager and (new) portal — none for base.group_user. Odoo ORs only the rules of groups that have rules, so a Registry Viewer / Global Finance / Program Viewer with just base.group_user can read every grievance in the database over RPC — the same PII exposure #380 describes, one line above the line this PR changed. 19.0.2.0.1 gated the Helpdesk menu from those roles, which hides the UI, not the RPC.

4. spp_grm_cel/models/grm_escalation_rule.py:422 — the post-write steps can never trigger a rollback, and now fail silently under owner identity.
HISTORY.md and the PR body claim "if any post-write step — the chatter post, notification, or case creation — is denied … the whole escalation rolls back". But _send_escalation_notification (line 445) and _create_case_from_ticket (line 466) each wrap everything in except Exception: _logger.error(...), so no AccessError from them ever reaches the savepoint. Worse: these steps previously ran as the superuser cron and now run as the rule owner, so an officer-owned rule with create_case=True hits AccessError on self.env['spp.case'].create(...), the case is silently never created, and apply_escalation still returns True and increments the counter. Same for a mail.template the officer cannot read.

5. spp_grm_cel/models/grm_escalation_rule.py:615 — the savepoint catches only AccessError, so anything else aborts the whole cron pass.
Under owner identity the ticket write is far more likely to raise something else: a ValidationError/UserError from a spp.grm.ticket constraint, a MissingError if the ticket vanished since check_escalations snapshotted it (line 524), or a serialization failure from the raw cr.execute counter UPDATE (line 435). Any of those propagates out of apply_escalations, out of check_escalations, and the remaining tickets in the pass are never processed. check_escalations has no per-ticket isolation; the savepoint added here was the natural place to add it.

6. spp_grm_cel/models/grm_escalation_rule.py:146 — an archived owner keeps evaluating rules, with no reassignment path.
HISTORY.md tells operators "a user who owns rules can no longer be deleted (ondelete="restrict") — archive them instead". But rule.eval_as_user_id reads back the archived user regardless of active, and with_user(owner.id) accepts an inactive uid, so an offboarded officer's always-match rule keeps routing/escalating with their old record-rule scope indefinitely. There is no way to reassign: the field is readonly=True and write() (line 189) unconditionally pops any client-supplied eval_as_user_id.

7. spp_grm_cel/migrations/19.0.2.0.2/post-migration.py:46 — the documented remediation does not work.
"Re-save each as the user who should own it": write() (grm_escalation_rule.py:189, grm_routing_rule.py:150) re-binds eval_as_user_id only when a member of _EVAL_TARGETING_FIELDS is present in vals. The Odoo web client sends only modified fields on save, so an admin who opens a uid-1-owned rule and presses Save submits {} (or only name) and nothing re-binds — the rule keeps evaluating with record rules fully bypassed. The same wording is repeated in the engine warning (line 584) and in spp_grm_cel/readme/HISTORY.md, so an operator following the instructions will believe the rule is scoped when it is not.

Performance / log noise

8. spp_grm_cel/models/grm_escalation_rule.py:584 — owner WARNINGs are emitted once per rule per ticket.
The superuser-owner and no-owner WARNINGs sit inside the per-rule loop of apply_escalations, which check_escalations (line 517) calls once per open ticket. A DB with 5,000 open tickets and 2 rules created from a shell/import (create_uid = 1, the exact case the migration warns about) emits 10,000 identical WARNING lines every hour, forever. Same shape at grm_routing_rule.py:301/313, once per ticket create. The hoisting fix applied to the rule search was not applied to these logs — they belong in check_escalations (once per pass) or behind a per-rule dedupe.

9. spp_grm_cel/models/grm_escalation_rule.py:517 — already-escalated tickets are re-escalated every hour.
check_escalations searches [('is_closed','=',False)] only, with no is_escalated / escalation_rule_ids guard. A ticket that matched at 09:00 and is still open at 10:00 matches again: apply_escalation rewrites is_escalated/escalation_date, posts another "Ticket escalated by rule" chatter message (now reordered to run before the notification, so it fires every time), re-sends the template email, and bumps escalation_count. spp_grm/models/grm_ticket.py:553 explicitly guards this on the SLA path (if ticket.is_escalated: continue); the cron does not. Pre-existing, but this PR makes the counter and the chatter post the load-bearing parts of the escalation and adds a savepoint around them.

10. spp_grm_cel/models/grm_escalation_rule.py:398 — two consecutive ticket.write() calls; the second is what actually raises.
Line 397 writes team_id/user_id/severity, then line 398 writes escalation_rule_ids in a separate call. write() re-runs check_access('write'), which flushes team_id and re-evaluates the officer's record rule against the just-changed value, so the AccessError in test_officer_escalation_out_of_scope_rolls_back_cleanly comes from line 398, not from the message_post at line 413 — the chatter/notification reordering is not the mechanism the comment describes. Merging both dicts into one ticket.write() removes the extra round-trip and the spurious re-check, and makes the comment at line 407 accurate about what protects the transaction.

Reuse / clarity

11. spp_grm_cel/models/grm_escalation_rule.py:142 — the owner-identity mechanism is copy-pasted, and a third copy is in flight.
~60 near-identical lines (field + no-default comment + _EVAL_TARGETING_FIELDS + create/write overrides + the with_user/AccessError-skip loop) appear in grm_escalation_rule.py:142-200 and grm_routing_rule.py:108-162, comments included. This PR's own commit message records having to "re-sync owner-identity create/write docstrings between the two rule models" — the drift already happened once inside a single PR. PR #364 (open, spp_alerts, "evaluate alert rules as their owner") implements the same mechanism a third time, and nothing matching it exists in spp_alerts today, so the "see spp_alerts #364" citations point at code that is not in the tree. This belongs in a shared abstract mixin in a foundation module, applied by all three models.

12. spp_grm_cel/models/grm_escalation_rule.py:407 — the comment states the wrong access requirement.
"posting needs write access on the ticket" is not correct: spp.grm.ticket sets _mail_post_access = "read" (spp_grm/models/grm_ticket.py:23), so message_post needs read only. The reasoning still lands here because the officer loses read as well when the ticket moves to another team, but a maintainer reading this comment will conclude message_post is write-gated on this model and reason wrongly about any future change to the record rules or to _mail_post_access.

13. spp_grm_cel/models/grm_routing_rule.py:157 — the write() re-bind guard evaluates the same scan twice.
Popping eval_as_user_id cannot change the result of any(f in vals for f in self._EVAL_TARGETING_FIELDS), so the inner if re-computes what the outer if already established. The whole block collapses to:

if any(f in vals for f in self._EVAL_TARGETING_FIELDS):
    vals = dict(vals, eval_as_user_id=self.env.uid)
elif "eval_as_user_id" in vals:
    vals = {k: v for k, v in vals.items() if k != "eval_as_user_id"}

Duplicated identically in grm_escalation_rule.py:189-196, so the confusing shape is maintained in two places.

Tests

14. spp_grm/tests/test_portal_ticket_acl.py — the portal submission route is untested.
The entire justification for reducing access_spp_grm_ticket_portal_user to 1,0,0,0 is "submission is handled by the sudo'd portal controller", but the new suite tests only that direct model create/write are denied. Nothing covers /my/ticket/submit (spp_grm/controllers/grm_portal.py:40) end to end. That route also does a non-sudo request.env.ref('spp_grm.grm_ticket_channel_web') and a non-sudo category/channel search in /my/ticket/new, neither of which this PR verified still works for a portal user. An HttpCase covering the submit route is missing — a regression there silently breaks all portal grievance submission with no failing test.

15. spp_grm_cel/tests/test_rule_acl.py:84 — two existing tests were deleted rather than adapted.
AGENTS.md, Tests section: "NEVER remove or weaken existing tests without explicit approval". test_rule_readonly_caller_escalation_increments_counter asserted three things (apply_escalations returned True, counter +1, ticket.is_escalated True) for a caller with read-only rule access; its stated replacement, test_escalation_counter_increments_under_owner_identity, asserts only the counter and drops both the return value and the is_escalated assertion. The PR body does not record approval for either deletion. It also proposes deleting trigger_after_hours / _check_time_trigger as "unused" — that method is in fact called from evaluate() at grm_escalation_rule.py:241, so acting on that follow-up would remove a live guard.

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.

security(spp_grm_cel): escalation/routing rules evaluate as superuser — GRM officer can seize every ticket [Severity: High]

2 participants