Skip to content

fix(api): bound update-any by its Constraints on planned-purchase DELETE - #1097

Merged
IAmJSD merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-961
Oct 23, 2026
Merged

IAmJSD merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-961

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

DELETE /api/purchases/planned/{id} is authorized by authorizePlannedPurchaseCancel (internal/api/handler_purchases.go). Its update-any:purchases branch granted full scope on the verb alone: it never evaluated the granting permission's Constraints (Providers, Services, Regions, AccountIDs). A session whose update-any was bounded to one provider could therefore cancel any user's scheduled purchase for any provider. This is the same SEC-01 fail-open class as #678 and #404. Per the maintainer's scope update on the issue, #978 covered the cancel-any and creator branches, and this PR covers the update-any branch.

Fix

This builds on #975, #978 and #1001 and reuses their helpers unchanged:

  • A new authorizePlannedFullScope tries update-any and then cancel-any. For each one the session holds (sessionHoldsVerb, so a user API key is probed on the key, as in fix(api): evaluate planned-purchase full-scope verbs on the API key, not its owner #1001), it calls requirePermissionConstraints with revokeExecutionConstraintSets(execution). Those are the strict per-recommendation sets built from the stored row, never from the request.
  • Fallback: a Constraints 403 on update-any falls through to cancel-any, then to the creator branch (authorizePlannedCreatorCancel). That is the sec(auth): enforce per-permission Constraints on cancel-any/cancel-own (SEC-01 follow-up to #404) #678 fallback, now with one more verb in front.
  • Terminal denial: a non-creator gets the constraint message, not the ownership 403. The last constraint denial is passed to the creator branch, the same mechanism as before.
  • Fail closed: a probe error or a constraint-store error is returned as-is. It is never read as "not held" or as a denial, so it cannot fall through to cancel-any or to the creator branch.
  • authorizePlannedPurchaseCancel now loads the row before the full-scope checks, because update-any needs it too. Previously an update-any holder skipped that read. A missing row still maps to errNotFound.
  • Unchanged: the stateless admin API key bypass, the entry gate (requireDeleteOrCancelPurchasePermission), 404/403 concealment for callers without a verb, and the creator branch.

No migration and no DB write path changed. This is a read-then-CAS authorization check, the same shape as the cancel-any check from #978.

Closes #961

Tests

The new file is internal/api/handler_purchases_planned_update_any_test.go. Bearer-session answers come from the real matcher via grantPermissionsScoped (auth.PermissionsAllowForConstraintSets). The API-key cases run Handler.HandleRequest against a real auth.Service (key intersected with owner) through the existing #979 serveKeyScope harness.

  • TestDeletePlannedPurchase_UpdateAnyConstraintDimensions: one deny case per dimension (provider, service, region, account) asserts the 403 and that TransitionExecutionStatus is never called. One allow control per dimension, plus an unconstrained control.
  • TestDeletePlannedPurchase_UpdateAnyStrictScopeAndRecommendations:
    • an unknown region is refused by a region-bounded grant;
    • every recommendation must be in scope;
    • a row with no recommendations is refused by a bounded grant and allowed by an unbounded one.
  • TestDeletePlannedPurchase_UpdateAnyFallback:
    • out-of-scope update-any falls back to unbounded or in-scope cancel-any, to a creator holding delete, and to a creator with in-scope cancel-own;
    • it is denied, with the constraint message and no write, for a non-creator with delete, a non-creator with bounded cancel-any, a legacy NULL-creator row, and a creator with out-of-scope cancel-own.
  • TestDeletePlannedPurchase_UpdateAnyAdministratorsGroup: unbounded {admin,*} still cancels. {admin,*} bounded to another provider is refused, because admin matches update-any through the strict path.
  • TestDeletePlannedPurchase_UpdateAnyConstraintErrorDoesNotFallBack: an update-any constraint-store error propagates. This holds even though the caller is the creator and holds cancel-any.
  • TestDeletePlannedPurchase_APIKeyUpdateAnyFollowsKeyConstraints: real auth.Service. A key whose update-any is bounded to another provider gets a 403 and never reaches the mutation. An in-scope key is allowed.

Changes to existing tests:

  • TestDeletePlannedPurchase_CancelConstraints/"update-any is unchanged and unconstrained" asserted the bug. It now asserts the denial.
  • Five strict-mock admin tests in handler_purchases_test.go (TestHandler_deletePlannedPurchase, _DisablesPlan, _AlreadyDisabledPlan, _BackendErrorReturns5xx, _NilExecution) now stub the authorization read of the row with .Once(). In _BackendErrorReturns5xx the .Once() keeps its "no recovery-path read" assertion meaningful. Their other assertions are unchanged.

Verification (local, macOS)

Tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.

  • Fail-before / pass-after (unit tests on mocks and the real matcher): I reverted only internal/api/handler_purchases.go to origin/main (8caed39) and kept the new tests, then ran go test ./internal/api/ -count=1 -v -run 'UpdateAny|TestDeletePlannedPurchase_CancelConstraints'.
    • Before the fix: 15 deny cases failed (14 subtests plus TestDeletePlannedPurchase_UpdateAnyConstraintErrorDoesNotFallBack), and all 19 allow-control subtests passed.
    • With the fix: all pass.
  • Mutation probes on handler_purchases.go, all killed:
    • grant a held update-any without its Constraints (the original bug);
    • evaluate update-any's Constraints under the cancel-any verb;
    • non-strict (empty) constraint sets;
    • fall back on any error, not only a 403;
    • stop at the first denial with no fallback;
    • drop the terminal constraint denial (ownership message instead);
    • treat a probe error as "not held";
    • remove update-any from the full-scope verbs.
  • go build ./... and go vet ./...: clean.
  • go test -race -count=1 ./internal/api/: ok.
  • go test -count=1 ./internal/api/... ./internal/auth/... ./internal/server/... ./internal/mocks/: ok.
  • go test -tags integration ./internal/api/ -run 'TestDeletePlannedPurchase_RecordsCancelActor|TestNotifiedRow_' against real Postgres (testcontainers): ok. These are the existing DELETE-planned integration tests on a real store.
  • golangci-lint v2.10.1 run --allow-parallel-runners --build-tags integration ./internal/api/...: 0 issues.
  • gocyclo -over 10 -ignore "_test\.go" internal/api/: no output.
  • gosec v2.28.0 via scripts/gosec-hook.sh on the changed files: clean.
  • git-secrets --scan over the changed files, in a fresh temp repo with an isolated HOME: clean. bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.
  • Not verified against a real deployment. The authorization decision runs the real matcher and a real auth.Service over mocked stores.

Out of scope (noted, not changed)

Labels

Could a maintainer mirror the issue's triage labels onto this PR: priority/p2, severity/medium, triaged, urgency/this-quarter, impact/few, effort/s, type/security?

🤖 Generated with Claude Code

DELETE /api/purchases/planned/{id} granted full scope to any holder of
update-any:purchases on the verb alone, so a session whose update-any was
constrained to one provider, service, region or account could cancel any
user's scheduled purchase outside that scope.

authorizePlannedPurchaseCancel now evaluates update-any exactly as LeanerCloud#678
(LeanerCloud#978) evaluates cancel-any: strict per-recommendation constraint sets built
from the stored row (revokeExecutionConstraintSets), checked through
requirePermissionConstraints on the session's effective permissions (the key
for a user API key, LeanerCloud#979). A Constraints 403 falls through to cancel-any and
then to the creator branch; a non-creator keeps the terminal constraint
message. Probe and constraint-store errors refuse and never fall through.

Closes LeanerCloud#961

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 8caed39 and 7c6b0b0.

📒 Files selected for processing: 4
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_cancel_constraints_test.go
  • internal/api/handler_purchases_planned_update_any_test.go
  • internal/api/handler_purchases_test.go
+---------------------------------------------+
| Rabbit in the repo, here to fix that bug 🎶 |
+---------------------------------------------+
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

VERDICT: APPROVE 7c6b0b0

Reviewed the full diff of #1097 (one commit) against origin/main 56e226a. The base is 8caed39, 39 commits behind. git merge-tree against current main is clean (tree c68b1a85). The main commits that touch this route since the base are #1158 (daf8713, requirePlannedCancelPlanScope and disablePlanAfterCancel) and #1134 (e0215b0, the bounded update-any check for pause and resume). #1097 also merges cleanly with #1192 on top.

Findings (none blocking):

Plan interaction (merged tree): deletePlannedPurchase runs authorizePlannedPurchaseCancel, then requirePlannedCancelPlanScope, then the cancel CAS, then disablePlanAfterCancel. A bounded update-any holder who passes the new check is still refused with a 403 by #1158's account check when the plan spans accounts outside their scope, and nothing is written. Unscoped callers are unchanged.

Local results (merged tree, macOS):

  • go vet and go vet -tags integration on internal/api: clean.
  • go test ./internal/api/: ok.
  • go test -tags integration ./internal/api/ -run 'DeletePlanned|NotifiedRow|PlannedPurchase|Cancel' (testcontainers Postgres): ok.

Attacked (mutation probes on handler_purchases.go in a scratch copy, all killed):

  • M1, held update-any granted without Constraints: CancelConstraints/update-any, APIKeyUpdateAny, AdministratorsGroup and ConstraintErrorDoesNotFallBack failed.
  • M2, a non-403 error falls through to the next verb: ConstraintStoreErrorDoesNotFallBack failed.
  • M3, update-any removed from the verb list: PlannedPurchaseRoutes_FullScopeFollowsTheAPIKey/delete and APIKeyUpdateAny failed.
  • M4, terminal denial dropped: StrictScope and AdministratorsGroup failed.
  • M5, probe error read as "not held": APIKeyFullScopeProbeErrorRefuses/delete failed.
  • M6, non-strict empty constraint set: StrictScope and APIKeyUpdateAny failed.

Also checked:

  • Unbounded update-any still cancels.
  • Cancel-own still works through the creator fallback (existing CancelConstraints tests pass).
  • The admin API key bypass is unchanged.
  • A row with no recommendations is refused by a bounded grant.
  • Updating the existing test that had asserted the bug ("update-any is unchanged and unconstrained") was correct.

@cristim

cristim commented Oct 20, 2026

Copy link
Copy Markdown
Member

Independent review 2 (claude-fleet, cold): APPROVE 7c6b0b0

Findings (none blocking):

  • [non-blocking] internal/api/handler_purchases.go:410-418 - The doc on requireDeleteOrCancelPurchasePermission still says "update-any already grants full-scope cancel one step later". After this PR, update-any is bounded by its Constraints, so the comment overstates the entry gate.
  • [non-blocking] The PR body's "Out of scope: Pause, resume" is stale. origin/main e0215b0 (sec(api): planned-purchase pause/resume ignore the update-any permission's Constraints #1098) already added authorizeExecutionPauseResume with update-any Constraints. The body needs correcting, not the code.
  • [non-blocking] Duplication: the creator check in authorizePlannedCreatorCancel (handler_purchases.go:522) duplicates isExecutionCreator, which main added in e0215b0. Main's version also rejects an empty session UserID. Reusing the helper after a rebase would be a reasonable follow-up.
  • [non-blocking] In the merged tree, DELETE reads the row up to 3 times for a scoped session: requireExecutionAccess, the new authorization read and main's requirePlannedCancelPlanScope (daf8713). An unrestricted admin now costs 1 read where it used to cost 0. This is a performance cost only, and the result is correct.

Against #961: this fixes the root cause.

  • In authorizePlannedFullScope (handler_purchases.go:479-500), update-any now goes through the same strict per-recommendation check as cancel-any: requirePermissionConstraints plus revokeExecutionConstraintSets on the stored row.
  • Fallback and terminal-denial semantics match sec(auth): enforce per-permission Constraints on cancel-any/cancel-own (SEC-01 follow-up to #404) #678.
  • Refuted: a (nil, nil) from GetExecutionByID. The Postgres store returns ErrNotFound for a missing row (internal/config/store_postgres.go:1896).
  • Refuted: isForbidden missing the wrapped constraint error. IsClientError uses errors.As (handler_router.go:102).
  • Blast radius: deletePlannedPurchase (handler_purchases.go:575) is the only caller. No exported or route changes, and no migration.

Attacked:

  • Mutation probes on a scratch copy, each restored and confirmed identical with cmp. Each was caught:
    • Constraints evaluated under cancel-any instead of the held verb: 4 tests FAIL.
    • Fallback on any error, not only a 403: ConstraintStoreErrorDoesNotFallBack FAILS.
    • Terminal denial dropped: CancelConstraints and StrictScope FAIL.
    • Non-strict {{}} constraint sets: APIKey, AdministratorsGroup and CancelConstraints FAIL.
    • Verb order swapped: ConstraintErrorDoesNotFallBack FAILS.
    • A held update-any granted unconstrained (the original bug): APIKey, ConstraintError and StrictScope FAIL.
  • Covered by tests and consistent with the code: a bounded {admin,*}, the API-key path, empty recommendations and the legacy NULL creator.

Verified locally, on git archive exports with no cloud credentials, under the heavy lock:

  • Head: go build ./... and go vet ./internal/api/: OK. gofmt -l on the 4 changed files: clean. go test -count=1 ./internal/api/: ok.
  • Merged tree:
    • git merge-tree --write-tree origin/main 7c6b0b0 is clean. Main changed handler_purchases.go in e0215b0 and daf8713.
    • Build, vet and go test -count=1 ./internal/api/ on that tree: ok.
  • Fail-before:
    • With the head's test files on base, all 6 new UpdateAny tests and CancelConstraints FAIL.
    • The 3 admin fixture tests also fail, as expected, on their now-unmet GetExecutionByID stubs.
  • Integration tests were not run. There is no store change.

Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged.

@IAmJSD

IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at 7c6b0b07b909dd93f5b9b141dc5d805f55b3bf6b merged onto current main (61bf6070; merge-tree 1ed3bc87; macOS):

  • go build ./..., go vet ./... and go vet -tags integration ./... pass.
  • go test -race -short -count=1 on the packages this PR changes passes: ./internal/api.
  • The full go test -race -short ./... suite passes on the tree with every PR in this merge train applied (tree 99ee50e2).
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 23, 2026 00:14
@IAmJSD
IAmJSD merged commit 3c726d2 into LeanerCloud:main Oct 23, 2026
25 checks passed
@IAmJSD

IAmJSD commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at 7c6b0b07b909dd93f5b9b141dc5d805f55b3bf6b).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(api): planned-purchase DELETE ignores the granting permission's Constraints

2 participants