fix(api): report an authorization failure as 403, not 409 - #460
Conversation
AccessError subclasses UserError in Odoo, so the change-request state transitions — $submit, $approve, $apply and $reset — which caught UserError and returned 409 Conflict reported permission failures as conflicts. The client is told to resolve a conflict it cannot see, and one that retries on 409 (reasonable for a genuine conflict, which may clear) loops on a permission error that never will. It is most visible on $apply now that applying requires the change-request manager role: the endpoint's own scope check already returns 403, so the same endpoint reported two authorization failures with different statuses. The mapping lives in one helper rather than a fifth copy of the same except block, and a test fails if a handler goes back to a hard-coded status — this is exactly the bug that returns when the next endpoint is copy-pasted. ValidationError has the same shape (it also subclasses UserError, so a validation failure reports 409 where create() uses 422). Current behaviour is pinned by a test with a note rather than changed, being a separate API-contract decision.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## 19.0 #460 +/- ##
==========================================
+ Coverage 76.28% 76.37% +0.09%
==========================================
Files 654 654
Lines 44035 44077 +42
==========================================
+ Hits 33592 33666 +74
+ Misses 10443 10411 -32
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
gonzalesedwin1123
left a comment
There was a problem hiding this comment.
The core fix is right and I verified it: AccessError subclasses UserError at odoo/exceptions.py:77, _status_for_odoo_error checks the subclass first (the only ordering that works), the suite is 85/85, and the bite check is precise — restoring the bug fails exactly test_access_error_is_forbidden and test_access_error_is_not_shadowed_by_its_base_class and nothing else. The extracted helper and the intent behind the meta-test are both good.
Requesting changes because the fix is incomplete, and — the more concerning part — the meta-test that exists to prevent that incompleteness is blind to the two handlers it missed. The two are coupled.
Must fix 1 — $reject and $request-revision have the identical AccessError→409 bug, unfixed
change_request.py:471 (reject_change_request) and :512 (request_revision_change_request) are also state transitions — _do_reject() / _do_request_revision() (services/change_request_service.py:510-529) write records through the ORM and can raise AccessError from the detail-model record rules added in #261 — but both still catch:
except (UserError, ValidationError) as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from eso an authorization failure here returns 409, the exact bug this PR removes from the other four. Both require the change_request:approve scope (the endpoint-level check already returns 403 at :454/:496), so the same endpoint reports two authz failures under two statuses — the same inconsistency the PR body calls out for $apply. (Inline anchoring isn't possible here since these lines aren't in the diff, hence the body.)
Fix — collapse each to the uniform form (both AccessError and ValidationError are UserError subclasses, so except UserError still catches both, and _status_for_odoo_error keeps ValidationError at 409 via fall-through):
except UserError as e:
raise HTTPException(status_code=_status_for_odoo_error(e), detail=str(e)) from eThis also brings both handlers back into the meta-test's view (Must fix 2, inline on the test).
Also noted, not changed (candidates for the same follow-up)
- ValidationError status is inconsistent.
create()(:95) andupdate()(:345) mapValidationError→ 422, while the transition handlers map it → 409 (explicitly in reject/request-revision, by fall-through in the four fixed ones). reject/request-revision raiseValidationErrorfor missingreason/notes— "missing required input", which is 422 elsewhere. Defensible to leave for a scoped fix, but if you're already editing those two handlers for finding 1, consider aligning to 422 there. - Other
UserErrorsubclasses fall through to 409.AccessDenied(arguably 403) andMissingError(arguably 404) both subclassUserError;LockError→409 is correct.isinstance(exc, (AccessError, AccessDenied))would make the helper principled rather than AccessError-special-cased. Low priority. detail=str(e)returns raw Odoo messages to the client. For a typical API service user anAccessErrorbody is just friendly model text plus the service account's name/id (no registrant PII), and this PR doesn't worsen it — every sibling handler already returnsstr(e). But this is now the path you're deliberately routing to clients, anddocs/principles/api-error-responses.mdcalls for RFC 9457 Problem Details / anti-enumeration. Worth a follow-up to return a generic 403 detail on the AccessError branch.create()also mapsAccessError→500 via a bareexcept Exception(:100) — same class, different endpoint, same follow-up.
Verified clean
Single router in the module (no other UserError/AccessError/hard-coded-status site beyond the transition set; the 409s at :333/:339 are update()'s genuine optimistic-lock / non-draft guards, correctly left alone). Version 2.0.1→2.0.2 correct; HISTORY OCA newest-first; README/index.html regenerated consistently with renumbered anchors; changed-file set is exactly the 7 expected (no cross-module README drift); AccessError import added alongside first use.
| from ..routers import change_request as module | ||
|
|
||
| source = inspect.getsource(module) | ||
| blocks = source.split("except UserError as e:")[1:] |
There was a problem hiding this comment.
Must fix (Important 2): this guard is blind to the tuple form and passed over two live instances of the very bug it guards. The split token is the literal "except UserError as e:", but reject/request-revision read except (UserError, ValidationError) as e:, which does not contain that substring ('except UserError as e:' in 'except (UserError, ValidationError) as e:' → False). So test_every_state_transition_handler_uses_the_mapping inspects only the four already-fixed handlers and reports green while the two buggy ones sail through. Had the token matched, this test would have failed on this PR and forced the complete fix.
It's also gameable by trivial rewrites (as exc:, bare except UserError:) and the block[:200] window is fragile. In order of preference:
- Best — a real end-to-end test. Call one transition endpoint as an authenticated client whose Odoo user is denied by a record rule, and assert HTTP 403. That exercises the AccessError→403 path through an actual handler; the current unit tests only call
_status_for_odoo_errorin isolation and never through a route. - At minimum, broaden the source scan to match every
exceptclause namingUserError(e.g. a regex overexcept .*UserError.*:then assert the following lines reference_status_for_odoo_error), so the tuple form — and any future one — is covered. Once finding 1 collapses both handlers toexcept UserError, today's split would catch them, but broaden the token anyway so it can't regress.
There was a problem hiding this comment.
Both done in cbed615:
Broadened scan — the guard now walks the AST instead of splitting on a source substring: every except handler whose caught types name UserError (tuple form, renamed binding, attribute form included) must reference _status_for_odoo_error in its body. Re-introducing either missed spelling now fails the test with the offending line number.
Route-level tests — TestTransitionRoutesStatusMapping calls $reject, $request-revision and $apply through the real router with FastAPITransactionCase, asserting HTTP 403 for AccessError and 409 for plain UserError through the actual handlers.
One honest deviation from your option 1: the denial is injected at the ChangeRequestService boundary (patch.object(..., side_effect=AccessError(...))) rather than provoked by a record rule. The CR record rules apply a single domain to read and write alike (rule_cr_user), so a CR that is readable but not writable cannot be constructed from data — a rule-denied user 404s at find_by_reference before ever reaching the except clause — and _check_can_reject's own guard raises UserError, not AccessError. The route, handler, and except clause under test are the real ones; only the exception's origin is synthetic.
… as 403 too The same AccessError-shadowed-by-UserError bug fixed on the other four state-transition endpoints: both handlers caught (UserError, ValidationError) and returned a hard-coded 409, so an authorization failure surfaced as a conflict. Both now use _status_for_odoo_error; ValidationError still falls through to 409 unchanged, as it subclasses UserError. AccessDenied now maps to 403 alongside AccessError, matching the platform's global FastAPI error handler. The source-scan guard was blind to the tuple form of the except clause, which is exactly how the two missed handlers spelled it; it now matches except handlers on the AST. New route-level tests exercise the mapping through the real FastAPI handlers over HTTP.
|
Review addressed in cbed615. Must fix 1 — Must fix 2 — the meta-test matches except-handlers on the AST and new route-level tests exercise the mapping through the real handlers over HTTP; details in the inline reply. Also taken from the noted list: Follow-up PR raised: #471 (stacked on this branch) implements the remaining noted items — Full module suite: 91 tests, 0 failures, 0 errors (was 85). One observation from writing the route tests, out of scope for both PRs: |
gonzalesedwin1123
left a comment
There was a problem hiding this comment.
Re-review of cbed615 — both must-fixes verified, approving.
- Must fix 1:
$rejectand$request-revisionnow useexcept UserError+_status_for_odoo_error(e), identical to the other four transition endpoints. I swept every handler in the router: the remaining hard-coded 409s are the update endpoint's optimistic-lock mismatch and non-draft guard — genuine conflicts, correct as they are — and theexcept ValidationError→ 422 exemption is sound, since anAccessErroris not aValidationErrorand propagates to the platform's global handler (403). - Must fix 2: the guard now matches
excepthandlers on the AST (tuple forms, attribute references, renamed bindings all covered) and asserts_status_for_odoo_erroris used in the handler body — it would have caught both handlers the old substring version missed. The newFastAPITransactionCaseroute tests exercise the real handlers over HTTP with theAccessErrorinjected at the service boundary, which is the right seam given the symmetric read/write record rules. AccessDenied→ 403: correct and properly grouped — it subclassesUserError(so the handlers catch it) but notAccessError(so the isinstance tuple is needed), and it mirrorsfastapi/error_handlers.py.
HISTORY rewrite covers all six endpoints, version stays 19.0.2.0.2 (unreleased, same PR), CI fully green including codecov. The remaining noted items are #471's scope as agreed.
Merge authorization is Edwin's, as usual.
#460 was squash-merged as 3be1c39 with a module tree byte-identical to cbed615 (this branch's base for spp_api_v2_change_request), so every conflict is squash-vs-original textual noise. Resolved by keeping this branch's side for the module, which makes the result exactly 19.0 + this PR's own changes.
An authorization failure on a change-request state transition is reported as
409 Conflictinstead of403 Forbidden.The bug
AccessErrorsubclassesUserErrorin Odoo (odoo/exceptions.py:77). Four endpoints inspp_api_v2_change_request/routers/change_request.py—$submit,$approve,$applyand$reset— did this:so any
AccessError— from a record rule, or from an authorization guard — surfaced as a conflict. That is wrong twice over:Why it matters now
$applyrequires the change-request manager role as of the batch-2 work (#422, #365). That guard raisesAccessError, so the documented operator instruction — "Deployments applying CRs via API must grant that role" — currently manifests as a 409, which reads as retryable rather than as "you lack permission".The endpoint's own client-scope check already returns 403, so today the same endpoint reports two authorization failures with two different statuses.
The fix
The mapping moves into
_status_for_odoo_error(), and each handler calls it — one line per site, rather than a fifth copy of the same block. All four endpoints are corrected, not just$apply: any of them can raiseAccessErrorfrom a record rule, and the change-request detail models gained record rules in #261.Tests
Five, including two beyond the obvious:
test_access_error_is_not_shadowed_by_its_base_classassertsAccessErroris aUserErrorand that the two still map differently, encoding the root cause rather than the symptom.test_every_state_transition_handler_uses_the_mappinginspects the router source and fails if any handler reverts to a hard-coded status — this is precisely the bug that comes back when the next endpoint is copy-pasted.Full
spp_api_v2_change_requestsuite: 85 tests, 0 failures.Noted, not changed
ValidationErroralso subclassesUserError, so a validation failure on these endpoints reports 409 wherecreate()uses 422 for the same condition. Current behaviour is pinned by a test with a note; changing it is a separate API-contract decision.Merge order
Independent — no other open PR touches
spp_api_v2_change_request.19.0.2.0.1 → 19.0.2.0.2. Worth landing alongside #422, since that PR's release notes describe the manager requirement whose error this corrects.