Repository navigation
sec(auth): enforce per-permission Constraints on execute-any/own, approve-any/own, runPlanned (SEC-01 follow-up to #1210) #60
Description
Problem
PR LeanerCloud/cloud-commitments-cli#1210 fixes SEC-01 (issue LeanerCloud/cloud-commitments-cli#1141) for execute:purchases and execute:ri-exchange by enforcing per-permission Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) at execution time. The same fail-open class still exists on the sibling money-spending verbs whose constraints are also silently ignored:
execute-any:purchases/execute-own:purchases—Handler.authorizeSessionExecuteDirect(internal/api/handler_purchases.go:792) gates the direct-execute path with a bareHasPermissionAPI(action=execute-any, resource=purchases)/HasPermissionAPI(action=execute-own, ...)check. If the granting permission carriesConstraints(e.g.MaxPurchaseAmount: $500,Providers: [aws]), they are not evaluated. A user who satisfies the upstreamexecute:purchasesconstraint set (PR fix(auth): enforce per-permission constraints on execute paths cloud-commitments-cli#1210) but whoseexecute-any:purchasesis capped tighter can still direct-execute past the tighter cap.approve-any:purchases/approve-own:purchases—Handler.authorizeSessionApprove(internal/api/handler_purchases.go:592) gates session-approve via the bare verb check.approvePurchaseViaSessionthen callspurchase.Manager.ApproveAndExecutewhich synchronously fires the AWS purchase (internal/api/handler_purchases.go:572); the approve path is also an execute path, but the approve permission's Constraints aren't enforced.runPlannedPurchase(internal/api/handler_purchases.go:306) callsrequirePermission("execute", "purchases")then transitions the planned execution torunningwithout consulting Constraints against the persisted recommendations. A user who runs a plan they didn't create (viaupdate-any:purchases) bypasses any Constraints on their ownexecute:purchasespermission.
All three are the same fail-open class PR LeanerCloud/cloud-commitments-cli#1210 fixed for the web execute path: a bare verb gate runs, the matcher's nil request-side constraints short-circuit checkPermissionConstraints to allow, and the configured caps are silently ignored on a money-mutation path.
Fix sketch
Apply the same requirePermissionConstraints pattern PR LeanerCloud/cloud-commitments-cli#1210 introduced:
- For
execute-any/execute-own: build the same per-rec constraint sets aspurchaseConstraintSetsinvalidateExecutePurchaseRequestand callrequirePermissionConstraints(session, "execute-any" or "execute-own", "purchases", sets)insideauthorizeSessionExecuteDirectonce the verb branch is chosen. - For
approve-any/approve-own: inauthorizeSessionApprove, after the verb branch is chosen, re-hydrate the execution's recommendations and buildpurchaseConstraintSetsagainst them before allowingApproveAndExecuteto fire. - For
runPlannedPurchase: re-hydrate the planned execution's recommendations and callrequirePermissionConstraints(session, "execute", "purchases", purchaseConstraintSets(...))before thepending|paused → runningtransition. Same shape as the web execute path.
Regression tests should mirror the SEC-01 ones in TestHandler_executePurchase_PermissionConstraintsDenied: a session that passes the bare verb gate but whose constraints reject the request returns 403 before any state mutation / SDK call.
Triage
type/security, priority/p1, severity/high, urgency/soon, impact/all-users, effort/m, triaged. Same severity as the parent SEC-01: a constrained purchaser can spend past their configured cap via the unfixed verb on a money path.
Activity
cristim commentedon Jul 28, 2026
Reviewed commit:
be11bdcb5. Note:origin/mainmoved to3e9660d06during the review; re-verify against currentmainbefore changing code, since a finding may have been fixed or moved.Re-derived independently during the 2026-07-28 full review of
internal/apiand confirmed still live onbe11bdcb5. This issue is the right home; the review adds a complete census that is wider than the three verbs in the title, plus a sharper failure scenario. Please widen the acceptance criteria (or spawn one follow-up) rather than closing after the three named sites.Census:
requirePermissionConstraintshas exactly two call sites in the whole packagePresent:
internal/api/handler_purchases.go:2101(enforcePurchaseConstraints, used by direct-execute and retry)internal/api/handler_ri_exchange.go:774(executeExchange)Missing, all of them mutating and most of them money-moving:
approvePurchaseViaSessioninternal/api/handler_purchases.go:665approveViaTokeninternal/api/handler_purchases.go:588runPlannedPurchaseinternal/api/handler_purchases.go:302upsertLadderConfiginternal/api/handler_ladder.go:77updateConfiginternal/api/handler_config.go:59updateRIExchangeConfiginternal/api/handler_ri_exchange.go:963marketplaceListinternal/api/handler_marketplace.go:139revokePurchaseinternal/api/handler_purchases_revoke.go:139This issue names
execute-any/own,approve-any/ownandrunPlanned. The five sites below those (ladder config, global config, RI-exchange config, marketplace listing, revoke) are the same fail-open class on the sameConstraintsstruct and are not covered anywhere else.marketplaceListandrevokePurchasein particular move money in the seller/refund direction, whereMaxPurchaseAmountis not the relevant constraint butProviders/AccountIDsare.Sharper failure scenario for the approve path
An admin grants U
approve-any:purchaseswithConstraints{MaxPurchaseAmount: 1000, Regions: ["eu-west-1"]}. The UI presents this as a $1,000 ceiling.us-east-1purchase: blocked athandler_purchases.go:2101.us-east-1execution created by anyone else:approvePurchaseViaSessioncallsauthorizeSessionApproveand thenpurchase.ApproveAndExecutewith no constraint evaluation at all.The configured cap is silently inert on the one path that actually spends the money, while appearing enforced on the path that does not.
runPlannedPurchasehas the identical shape for scheduler-created rows.Note on the two independent gates
Worth stating explicitly in the fix, because they are easy to conflate:
getAllowedAccounts(session/group scope) andrequirePermissionConstraints(per-permissionConstraints) are independent gates. A permission with noAccountIDsconstraint satisfies the second unconditionally (empty-permission-side rule,internal/api/handler.go:450-461), so passingrequirePermissionConstraintsis not evidence that account scoping happened. Several handlers in this package satisfy exactly one of the two gates and read as guarded.Fix direction (unchanged in shape, wider in coverage)
Build the constraint set from the execution's stored
Recommendations(reusepurchaseConstraintSets,handler_purchases.go:2122-2183, whose evasion paths are already closed) and callrequirePermissionConstraints("approve", "purchases", ...)on both approve branches and inrunPlannedPurchase. Then do the same for the five config/marketplace/revoke sites with whichever constraint fields apply.The 2026-07-28 full-repo review re-derived this and confirmed all three paths listed here are still unguarded on
be11bdcb5. Two additions: two more money verbs that are not in this issue's scope list, and a concrete operator-facing scenario worth attaching to the acceptance criteria.Where
internal/api/handler_purchases.go:2100(enforcePurchaseConstraints), called only frominternal/api/handler_purchases.go:2086andinternal/api/handler_purchases.go:1627internal/api/handler_purchases.go:307(run-now path)internal/api/handler_purchases.go:350(session-authed approve path)internal/auth/service_api.go:359(HasPermissionAPIpassesconstraints = nil)internal/auth/service_group.go:232(checkPermissionConstraints)What the review adds
Two more verbs belong in the scope list. This issue covers
execute-any/execute-own,approve-any/approve-own, andrunPlanned. The review found the same bare-verb gate on the revoke and sell paths, which are also irreversible money actions. WhateverrequirePermissionConstraintswiring lands here should cover all of them in one pass, otherwise the next review finds the same class again on the two that were left out.PermissionConstraintsis a fully surfaced product feature, which raises the cost of the gap beyond "a cap is not applied". All five dimensions (MaxPurchaseAmount,AccountIDs,Providers,Services,Regions) are configurable per group permission and are round-tripped through the API (internal/auth/service_api.go:51) and the group editor UI. So an operator can build a restricted role, see it stored and rendered, and reasonably believe it is enforced.Failure scenario
An operator creates a deliberately restricted role: a custom group holding
approve-any:purchasesconstrained toMaxPurchaseAmount: 100andAccountIDs: [dev-account]. A member of that group opens Purchase History and approves a pending $500,000 production execution. The constraint is stored, is rendered back in the group editor, and is silently ignored. The operator has a control that reads as enforced and is not, which is worse than not offering the control.Worth adding as a regression test alongside the
TestHandler_executePurchase_PermissionConstraintsDeniedmirrors already proposed here: a session that passes the bare approve verb but whoseMaxPurchaseAmountrejects the execution must return 403 beforeApproveAndExecutefires.Note on the failure direction
As this issue already states, these paths do not merely skip the check, they affirmatively pass it:
HasPermissionAPIsuppliesconstraints = nilandcheckPermissionConstraintsreturnstruewhenever the request-side constraints are nil. Recording it again only because it argues for the second half of the fix:checkPermissionConstraintsshould fail closed when the matched permission carries constraints but the caller supplied no request-side sets. Otherwise any future call site that forgets to build the sets inherits the same silent allow.sec(api): make approve constraint denial terminal, close token-flow b…
sec(api): make approve constraint denial terminal, close token-flow b…
test(api): cover runPlanned constraint and zero-commitment guards (#60)
test(api): assert runPlanned funnel not called on a locally typed mock (