Repository navigation
fix(approval): let email-link approvers load purchase details without view:purchases - #990
Conversation
… view:purchases
The approval deep link's confirm modal loads GET /api/purchases/{id}, which
needs view:purchases and account scope. The email-link approve is authorized
by the account's contact_email instead, so a contact approver without
view:purchases could approve but never see the modal.
Add POST /api/purchases/approve/{id}/details, authorized like the approve it
precedes: session and CSRF before the lookup; then either view:purchases with
the execution in scope, or a valid approval token AND a session email that is
the execution's contact approver. Every final denial answers as 404
"execution not found", like a missing ID (LeanerCloud#597). The deep link now uses this
read; History is unchanged. An expired token (410) is treated as permanent
in the toast.
Closes LeanerCloud#529
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 9
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of badc86a: draft: blocks ready
No BLOCKING code findings. Details below.
Findings
- NON-BLOCKING,
internal/api/handler_purchases_approval_details.go:63-66+internal/purchase/approvals.go:503: the approval token hash is only cleared on cancel, and the new route does not check execution status. A contact approver holding an unexpired token can still read details of an already-approved or completed execution until the 7-day expiry. They are already an authorized approver and see the same data in the email, so no cross-tenant exposure. The PR body already states this. Optional follow-up: restrict the token branch to pending executions. - NON-BLOCKING,
internal/api/handler_purchases.go:2569-2587(buildPurchaseDetailsResponse): the token branch returnserror(internal execution error text) and every recommendation of a multi-account execution, not just the contact's account. This matches the approve scope (authorizeApprovalActionaccepts the union of contact emails) and the email. Trimmingerrorfor this branch would be a follow-up product decision. - NON-BLOCKING,
frontend/src/purchases-deeplink.ts:84-100: the token stays inwindow.location.searchwhile the confirm modal is open and is only removed byreplaceStateafterwards. This is the existing behavior, not new in this PR. The new request carries the token in the POST body, not the URL.
What I attacked
- Authorization: token alone never authorizes.
getApprovalDetailsneeds a session plus CSRF before the lookup (requireSessionBeforeLookup), then eitherview:purchasesin scope, or contact-email match (authorizeApprovalAction) ANDValidateApprovalToken(hash match, then expiry). Same checks as approve for token and contact. An unknown token plus contact gets a concealed 404. - IDOR: the token hash is read from the same execution row that is fetched by the path ID, so a token for execution A cannot read B. The matrix tests cover wrong token, no token, execution without token, and not-the-contact.
- Oracle (#935/#597): every denial goes through
concealExecutionDenialto the missing-ID 404. The only differing answer is 410, which needs a correct token for that execution plus the contact gate, so it leaks nothing to a caller without the secret.SignedInCannotTellMissingFromExistingcompares status, headers and body across 6 callers. I did not find a gap. - Rate limit:
approvalDetailsHandlercallscheckRateLimit(..., "approve_cancel_public")before anything else (internal/api/router.go:612-617). Route is registered before the approve prefix route, andTestRouter_ApprovalDetailsDoesNotShadowApprovecovers shadowing. - Writes: the handler does not write.
- Frontend token: it goes in the POST body (
frontend/src/api/purchases.ts), History keeps the tokenless GET, and 410 is classed permanent. The existingconsole.errorlogs the error object, not the token. - Mutation probes (auth path, 3): drop contact gate, killed (3 test funcs FAIL); drop token validation, killed (
TokenAndContactBothRequiredFAIL); an earlier unrelated probe failed to build, so it was discarded. All reverted, worktree clean. - Fail-before/pass-after: jest
purchases-deeplinkagainst origin/main frontend sources fails to compile (TS2339getApprovalDetailsmissing); at the SHA 2 suites / 46 tests pass. Go:go test ./internal/api -run ApprovalDetailspasses at the SHA (67 passing test lines). The PR's own Go fail-before run (route missing, 400 UUID error) is plausible; I did not re-run it. Playwright not run by me.
Gate status (origin/main Review gate)
- Independent adversarial review of the full diff naming the SHA: this comment.
- CI on the SHA: NOT YET. 17 jobs pending, 6 pass (pre-commit passes), none failed or action_required at the time of review.
- CodeRabbit: skipped (draft). No actionable CR threads.
- Labels: match #529 (
triaged,priority/p2,severity/medium,urgency/this-sprint,impact/few,effort/m,type/bug). - Local verification: unit/mock only (testify mocks plus the #935 oracle fixture), labeled as such in the PR body. No real-account evidence.
Blocks ready
- Mark the PR ready for review (it is still a draft).
- Wait for the pending CI jobs to go green on this SHA.
- Rebase and re-review if main moves the router or
handler_purchases.go. - Optional: decide on the three NON-BLOCKING items (status gate,
errorfield, token left in URL while the modal is open), as follow-up issues rather than in this PR.
|
CI on badc86a is red because of this PR's new test, not flakiness. Unit and Integration both fail
The guard only accepts AssertNotCalled on a plain mock variable with a named method. Otherwise the assertion cannot fail. Assign the mock to a local (e.g. The independent review (no blocking code findings) covers badc86a. A new commit gets a delta review. |
TestNoUnfailableMockAssertions rejects AssertNotCalled on a non-plain receiver (a type assertion), since it can't prove the assertion can fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI fix at 1366784. The red Unit and Integration jobs were
🤖 Generated with Claude Code |
cristim
left a comment
There was a problem hiding this comment.
Independent delta review of 1366784 (previous: badc86a): no actionable findings
Delta: OLD is an ancestor of NEW; one new commit (1366784, test-only, +4/-2 in internal/api/handler_purchases_approval_details_test.go). No production, router or frontend change, so the earlier auth review of the handler (session + CSRF, view:purchases or contact + token, 404 concealment, shared approve_cancel_public bucket) carries over unchanged. Its three non-blocking notes (no status gate on the token branch, response includes error and all recommendations, token stays in the URL while the modal is open) are unchanged and remain non-blocking follow-ups.
Test fix: h.config.(*MockConfigStore).AssertNotCalled is now store := h.config.(*MockConfigStore); store.AssertNotCalled(...) at test lines 184-185 and 194-195, so TestNoUnfailableMockAssertions (CI failure at OLD) is satisfied.
What I attacked (temporary edits in a scratch worktree, each reverted):
- Handler calls h.config.GetExecutionByID before requireSessionBeforeLookup: both SessionAndCSRFBeforeLookup subtests (no session, contact without CSRF) fail with "GetExecutionByID was called 1 time(s)", so the not-called checks can still fail.
- Contact gate (authorizeApprovalAction) removed: TokenAndContactBothRequired, ViewPurchasesSession and SignedInCannotTellMissingFromExisting fail.
- Token validation (ValidateApprovalToken) removed: TokenAndContactBothRequired fails.
go test ./internal/mocks/ ./internal/api/pass; jest purchases-deeplink.test.ts 18/18 pass.- Full diff vs origin/main: merge-tree against current main is conflict-free; 395 additions (under the 400 cap).
Gate status (origin/main Review gate):
- Independent review: this comment names 1366784; the first review covered the code and the delta is test-only.
- CI on 1366784: all green (CI - Build & Test, pre-commit, Frontend E2E, Frontend build; Unit Tests, Lint, Integration, E2E, Security pass; CodeRabbit skipped for draft; Snyk skipped). No action_required runs.
- Local verification: handler tests exercise the real HTTP path through HandleRequest with mocked stores (mock evidence, not a real account); mutation probes above show fail-before/pass-after behavior.
- Remaining blocks: PR is still a draft (author must mark it ready); labels against #529 were not re-checked in this delta pass.
|
Gate record for 1366784 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 1366784 with --match-head-commit. |
… guard (#1003) #990 added POST /api/purchases/approve/*/details and #993 added the class-level guard that requires every AuthPublic route to be classified; they merged independently, so main fails TestAuthPublicRoutes_EveryRouteIsCSRFGuardedOrExempt. The route is read-only and already runs validateCSRF before the lookup, so it goes in the exempt list with that reason. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
#548 fixed the toast half of #529: 403/404 no longer say "retry". The backend half was still open. The approval deep link's confirm modal (
buildApprovalDetailsBody) loadsGET /api/purchases/{id}, which requiresview:purchasesand account scope. The email-link approve is authorized by the account'scontact_emailinstead (authorizeApprovalAction). So a contact approver withoutview:purchases, or outside the account scope, could approve but never load the details. Since #497 the modal refuses to open without them.Fix
Backend: a new read-only route,
POST /api/purchases/approve/{id}/details(internal/api/handler_purchases_approval_details.go). It is authorized like the approve it precedes, without the write:validateUUID, thenrequireSessionBeforeLookup. This means 401 without a session and 403CSRF validation failedwithout a valid CSRF header, both before the execution is read (sec(api): unauthenticated cancel/revoke distinguish missing (404) from existing (401) executions #435, sec(api): authenticated callers can still tell a missing execution from an existing one on approve, cancel and revoke #597).getExecutionOr404.view:purchasesand the execution in its account scope gets the response. This is the same gate and the same response body asGET /api/purchases/{id}, so nothing new is exposed to these callers.fallsThroughToToken), the request needs both:authorizeApprovalAction, unchanged), andpurchase.ValidateApprovalToken).concealExecutionDenial, so it is the same404 execution not founda missing ID gets (sec(api): authenticated callers can still tell a missing execution from an existing one on approve, cancel and revoke #597). The exception is an expired token: a caller who passed the contact gate with the correct token gets410, the same as the approve path.The route is
POSTso the token goes in the body and stays out of the Function URL access logs (#398). The issue's suggestedGET /api/purchases/approve/{id}path is already the side-effecting approve. The route goes in the existing/api/purchases/approve/AuthPublic prefix, is registered before the approve route (suffix/details), and shares theapprove_cancel_publicrate-limit bucket (30/min/IP). The handler does not write anything (the tests assert this).Frontend (small and isolated):
api.getApprovalDetails(id, token)is new.buildApprovalDetailsBody(id, token?)uses it when a token is passed.purchases-deeplink.tspassesdl.token.buildApprovalDetailsBody(id)with no token, so it keeps usingGET /api/purchases/{id}.isPermanentDetailsErroralso treats410as permanent, with the text "The approval link has expired." Without this, the new 410 would get the "retry" advice the issue complains about.Authorization matrix: reading details on the approval link
All callers are signed in, unless noted. "Valid token" means the token from this execution's email, not expired.
view:purchases, in scope, any/no tokenview:purchases, scoped out, no tokenview:purchases, scoped out, is contact, valid tokenview:purchases, is contact, valid tokenview:purchases, is contact, wrong/missing tokenview:purchases, is contact, execution has no tokenview:purchases, is contact, expired tokenview:purchases, not the contact, valid tokenapprove-own, not creator, not contact, any tokenThe token alone never authorizes. The contact email alone never authorizes. The approve, cancel and revoke routes are unchanged.
Verification
Everything here is unit/mock evidence: testify mocks plus the
oracleHandlerfixture from #935, all driven throughHandler.HandleRequest. Status writes in that fixture lose their CAS anyway. No real cloud calls, purchases, emails or deploys. Go tests ran withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null.New Go tests are in
internal/api/handler_purchases_approval_details_test.go:ContactApproverWithoutViewPurchases: the generic GET still gives 403, and the new route gives 200 with the recommendations and writes nothing.TokenAndContactBothRequired: wrong token, no token, execution without a token, expired token, and not-the-contact with a valid token.ViewPurchasesSession: in scope; scoped out and not the contact; scoped out and the contact.SessionAndCSRFBeforeLookup: assertsGetExecutionByIDis never called.SignedInCannotTellMissingFromExisting: status {pending, completed, Azure orphan} × token {none, valid, wrong} × the six fix(api): answer a hidden execution like a missing one on approve, cancel and revoke #935 callers. Each case asserts identical status, headers and body for an existing and a missing ID, and asserts no writes.TestRouter_ApprovalDetailsDoesNotShadowApprove.Fail-before / pass-after (run):
router.go, keeping the tests. All 6 new test functions FAIL. The key case fails withexpected: 200, actual: 400 {"error":"invalid ID format: must be a valid UUID"}, because the path fell through to the approve route. After the fix all pass.purchases-deeplinksuite fails to run (0 tests; I did not inspect the exact error, presumably the missinggetApprovalDetailsexport). After the fix: 18/18 pass.…/approve/{id}/details. I did not run them against the old bundle.Mutation probes on the new handler. Each was applied alone, then the new tests were run:
view:purchasescheckValidateApprovalTokenalways fails and that failure is concealed as the same 404, so no response can differ.fallsThroughToTokenis kept for parity withapprovePurchaseand to skip the contact lookup.Out of scope / for the maintainer
view:purchases, so the existing 403 text from fix(approval): stop telling permanent 403/404 details failures to retry #548 is less accurate on this path. A 404 now also covers "you are not this purchase's contact approver". I left the wording alone because you are doing UI work.GET /api/purchases/{id}: full recommendation records plusplan_nameanderror. That is what the modal'sPurchaseDetailstype expects, and it is roughly what the approval email already shows that contact. Trimming it would be a separate decision.getPurchaseDetailsstill has its own copy of the plan-name lookup. I did not refactor it.Closes #529
Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p2,severity/medium,urgency/this-sprint,impact/few,effort/m,type/bug.🤖 Generated with Claude Code