Repository navigation
fix(api): bound update-any by its Constraints on planned-purchase DELETE - #1097
Conversation
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>
|
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 |
|
VERDICT: APPROVE 7c6b0b0 Reviewed the full diff of #1097 (one commit) against origin/main 56e226a. The base is 8caed39, 39 commits behind. 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):
Attacked (mutation probes on handler_purchases.go in a scratch copy, all killed):
Also checked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 7c6b0b0 Findings (none blocking):
Against #961: this fixes the root cause.
Attacked:
Verified locally, on
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
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 |
Summary
DELETE /api/purchases/planned/{id}is authorized byauthorizePlannedPurchaseCancel(internal/api/handler_purchases.go). Itsupdate-any:purchasesbranch 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:
authorizePlannedFullScopetriesupdate-anyand thencancel-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 callsrequirePermissionConstraintswithrevokeExecutionConstraintSets(execution). Those are the strict per-recommendation sets built from the stored row, never from the request.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.authorizePlannedPurchaseCancelnow 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 toerrNotFound.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 viagrantPermissionsScoped(auth.PermissionsAllowForConstraintSets). The API-key cases runHandler.HandleRequestagainst a realauth.Service(key intersected with owner) through the existing #979serveKeyScopeharness.TestDeletePlannedPurchase_UpdateAnyConstraintDimensions: one deny case per dimension (provider, service, region, account) asserts the 403 and thatTransitionExecutionStatusis never called. One allow control per dimension, plus an unconstrained control.TestDeletePlannedPurchase_UpdateAnyStrictScopeAndRecommendations:TestDeletePlannedPurchase_UpdateAnyFallback: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: realauth.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.handler_purchases_test.go(TestHandler_deletePlannedPurchase,_DisablesPlan,_AlreadyDisabledPlan,_BackendErrorReturns5xx,_NilExecution) now stub the authorization read of the row with.Once(). In_BackendErrorReturns5xxthe.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.internal/api/handler_purchases.gotoorigin/main(8caed39) and kept the new tests, then rango test ./internal/api/ -count=1 -v -run 'UpdateAny|TestDeletePlannedPurchase_CancelConstraints'.TestDeletePlannedPurchase_UpdateAnyConstraintErrorDoesNotFallBack), and all 19 allow-control subtests passed.handler_purchases.go, all killed:go build ./...andgo 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.run --allow-parallel-runners --build-tags integration ./internal/api/...: 0 issues.gocyclo -over 10 -ignore "_test\.go" internal/api/: no output.scripts/gosec-hook.shon the changed files: clean.--scanover the changed files, in a fresh temp repo with an isolated HOME: clean.bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.auth.Serviceover mocked stores.Out of scope (noted, not changed)
authorizeExecutionManagement(POST /api/purchases/planned/{id}/pause|resume|run) also grants full scope on update-any without its Constraints. sec(api): planned-purchase DELETE ignores the granting permission's Constraints #961 names only DELETE./runre-checks execute Constraints before buying (fix(purchase): re-check purchase constraints on the claimed row before buying #1036), but pause and resume do not. This may deserve its own issue.delete:purchasesholders and does not evaluate delete's Constraints. The maintainer's scope update keeps sec(api): planned-purchase DELETE ignores the granting permission's Constraints #961 to the update-any branch.SavePurchaseExecution's upsert can rewriterecommendations, so a concurrent writer could in principle change a row's scope between the check and the cancel. I did not audit every caller for that. The window is the same one fix(api): enforce cancel permission constraints on planned purchase cancel #978's cancel-any path already has, and closing it would mean moving the check into the CAS, which is a wider change. I did not add a race test for it.sessionHoldsVerbor the auth helpers.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