Repository navigation
fix(api): evaluate planned-purchase full-scope verbs on the API key, not its owner - #1001
Conversation
…not its owner
authorizePlannedPurchaseCancel (DELETE /api/purchases/planned/{id}) and
authorizeExecutionManagement (POST .../pause, .../resume, .../run) granted
full scope on update-any (and DELETE also on cancel-any) by calling
HasPermissionAPI on session.UserID, i.e. the key's owner. A user API key
holding only a narrow verb (delete, cancel-own, update or execute) could
therefore act on any user's scheduled purchase whenever its owner held
update-any.
Both gates now use sessionHoldsVerb, the helper the creator branch already
uses: the key's effective permissions (key intersected with owner) for a
user-API-key session, the owner's for a user session. A probe error
refuses the request. Behaviour for bearer sessions and the admin API key
is unchanged.
Closes LeanerCloud#979
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: 4
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of fe70fbb: draft: blocks ready
No BLOCKING findings in the changed helpers.
Fix mechanism: both helpers now call sessionHoldsVerb (handler_purchases.go:519), which already existed on main and is used for cancel-own (:504). For session.UserAPIKeyID != "" it calls HasAPIKeyPermissionForConstraintsAPI (:523, auth/service_apikeys_api.go:412), which requires both the key's effective permissions and the owner's group permissions to allow the verb. This is the same API requirePermissionConstraints uses (handler.go:514). User sessions still call HasPermissionAPI with session.UserID (:525), same as before, and the apiKeyAdminUserID early return is unchanged. Per-account/creator checks in both helpers are untouched (only the verb probes changed). Error text is unchanged ("permission check failed: ...").
Findings (all NON-BLOCKING, follow-ups; recommend one tracked issue "owner-vs-key HasPermissionAPI on remaining purchase authz helpers"):
- Same owner-vs-key confusion remains outside these helpers: handler_purchases.go:1072/1080 (approve), :1121/1128, :1418/1426 (execute), :1951/1970 (cancel, other path), :2535/2543 (retry); handler_purchases_revoke.go:314/326, :415/427 (revoke); handler_ri_exchange.go:2429/2436, :2463/2471 (approve). handler.go:313/423 are the route-level gate and they do branch to the key path at :338/:445.
sessionHoldsVerbcould replace these directly. Not in scope of #979. - middleware.go:453 admin check on session.UserID: not reviewed for key semantics, include in the follow-up.
What I attacked:
- Mutation: authorizePlannedPurchaseCancel reverted to the owner check -> TestPlannedPurchaseRoutes_FullScopeFollowsTheAPIKey (delete: key delete / key cancel-own on another user's row) and ..._APIKeyFullScopeProbeErrorRefuses/delete fail. authorizeExecutionManagement reverted -> the same test fails for pause, resume, run, plus ..._ProbeErrorRefuses/pause. Both reverted, worktree clean.
- Fail-before/pass-after follows from the mutations (reverted code == pre-PR behaviour).
go test ./internal/api/...at this SHA: 3541 passed, 1 failed: TestAuthPublicRoutes_EveryRouteIsCSRFGuardedOrExempt (POST /api/purchases/approve/*/details, router.go:168). Unrelated to the diff and main has no diff in the CSRF files, so it is most likely failing on main too (not run separately on main).
Gate status:
- Independent review: this comment, SHA above.
- CI: runs pending on this SHA (Unit Tests, E2E, etc.); fork run, not yet green.
- Labels: match #979 (priority/p1, severity/high, urgency/this-sprint, impact/few, effort/s, type/security, triaged).
- CodeRabbit: draft, not reviewed; no threads.
- Branch is behind main (merge-base a74e6ea vs main 0ed85aa); rebase may be needed.
- Local verification: mocks-based unit tests exercising the real helpers with key and user sessions; no real-account run.
Blocks ready:
- Mark ready for review (author), CI green on the final SHA.
- Resolve the unrelated CSRF-guard test failure (or confirm it is red on main).
- Rebase onto current main, then this review needs a delta check.
- File the follow-up issue for the remaining call sites listed above.
|
Gate record for fe70fbb (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at fe70fbb with --match-head-commit. |
Summary
Closes #979.
Root cause
Two planned-purchase authz helpers decided full scope by asking
HasPermissionAPI(ctx, session.UserID, ...). That checks the key's owner, not the API key:authorizePlannedPurchaseCancel(DELETE /api/purchases/planned/{id}) checkedupdate-anyandcancel-anythis way.authorizeExecutionManagement(POST /api/purchases/planned/{id}/pause,/resume,/run) checkedupdate-anythis way. These are sibling routes with the same bug. sec(api): planned-purchase DELETE checks update-any on the key owner, not the API key's effective permissions #979 names only DELETE.So a user API key that held only a narrow verb, enough to pass the route's entry gate, could act on another user's scheduled purchase whenever its owner held
update-any. The narrow verbs aredeleteorcancel-ownfor DELETE,updatefor pause/resume andexecutefor run. For/run, that let the key force-run someone else's scheduled purchase. The fail-before test below confirms this reachesRunPlannedPurchaseNow.On DELETE, the owner-scoped
cancel-anycheck was already backstopped by #978: the follow-uprequirePermissionConstraintscall evaluates the key.update-anyhad no such backstop.Fix
Both helpers now call
sessionHoldsVerb, the existing helper #978 added for the creator branch of this same route. For a user-API-key session it checks the key's effective permissions throughHasAPIKeyPermissionForConstraintsAPI, which means the key intersected with its owner. For a bearer session it checks the user throughHasPermissionAPI, as before. A probe error refuses the request. It is never read as "not held", so it cannot fall through to the creator branch. The diff touches 3 call sites ininternal/api/handler_purchases.go. It adds no new helper and widens nothing.Authz matrix (row created by another user; the owner holds update-any + cancel-any + every narrow verb)
deletecancel-ownupdateexecuteupdate-anycancel-anyPlanned-purchase endpoints checked
GET /api/purchases/planned: read-only. It is gated onview:plansplus account scope, and has no full-scope verb check. Not affected.POST .../pause,POST .../resume,POST .../run: affected, fixed throughauthorizeExecutionManagement.DELETE .../{id}: affected, fixed throughauthorizePlannedPurchaseCancel.Tests
The new file is
internal/api/handler_purchases_apikey_scope_test.go.TestPlannedPurchaseRoutes_FullScopeFollowsTheAPIKey: drivesHandler.HandleRequest(router, middleware, handler) into a realauth.Servicebacked byauth.MockStore. Key validation, key-and-owner intersection and every permission decision are therefore the production code. Account scope, session validation and CSRF are mocked. For each of the 4 routes, and each narrow key verb, it covers:TransitionExecutionStatusorRunPlannedPurchaseNow;update-anyis allowed (the key path is not just always-deny);TestDeletePlannedPurchase_KeyCancelAnyKeepsFullScope: a key that holdscancel-anystill cancels another user's row.TestPlannedPurchaseRoutes_APIKeyFullScopeProbeErrorRefuses(pause + delete): anupdate-anyprobe error refuses the request and does not fall back to the creator branch.TestHandler_deletePlannedPurchase_UserAPIKey_Allowed,plannedKeyHandler,TestDeletePlannedPurchase_APIKeyVerbProbeErrorRefuses) now stub the key probes in place of the ownerHasPermissionAPIcalls. Their assertions are unchanged.Verification (local, macOS; tests run with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null)handler_purchases.gotoorigin/main(181af4f) and kept the new tests. All 5 "is refused" subtests failed: delete/delete, delete/cancel-own, pause, resume and run. Every control passed. With the fix applied, all pass.handler_purchases.goalone:authorizeExecutionManagementback to the ownerHasPermissionAPI: killed (pause/resume/run refusals).update-anyback to the owner: killed (both DELETE refusals).authorizeExecutionManagementignoresupdate-any: killed (owner-session andupdate-any-key controls).cancel-anyback to the owner: killed only by the strict-mock call pin inTestHandler_deletePlannedPurchase_UserAPIKey_Allowed. It is behaviourally equivalent through the real auth service, because fix(api): enforce cancel permission constraints on planned purchase cancel #978's key-scoped Constraints check denies anyway. That is the existing defence in depth, and this PR switches the check for consistency.go build ./...andgo vet ./...: clean.go test ./internal/api/... ./internal/auth/... ./internal/server/... ./internal/mocks/... -count=1: everything passes exceptTestAuthPublicRoutes_EveryRouteIsCSRFGuardedOrExempt. That test fails on cleanorigin/main(181af4f) too, with my changes stashed. RoutePOST /api/purchases/approve/*/detailsfrom fix(approval): let email-link approvers load purchase details without view:purchases #990 has no CSRF classification in test(api): class-level CSRF guard for AuthPublic money routes; remove the vacuous test and its dead code #993's guard. It is unrelated to this PR and left alone.run --allow-parallel-runners ./internal/api/...: 0 issues.gocyclo -over 10 internal/api/handler_purchases.go: no output.--scan --cachedover the changed files, in a temp repo with an isolated HOME: clean.bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.auth.Servicecode over a mocked store.Out of scope
update-anyanddeleteConstraints are still not applied on these routes. That is sec(api): planned-purchase DELETE ignores the granting permission's Constraints #961.getAccountScoperesolves allowed accounts from the key's owner (GetAllowedAccountsAPI(session.UserID)), not from the key. That is pre-existing and separate from the verb checks; not changed here.HasPermissionAPIonsession.UserIDfor their*-any/*-ownverbs. I did not change them. Several are backstopped by key-scopedrequirePermissionConstraints. They may deserve their own audit issue.🤖 Generated with Claude Code