@@ -1523,6 +1523,15 @@ func resolveOpsHint(failureReason string) string {
15231523// → may retry their own failed row.
15241524// - else → 403.
15251525//
1526+ // Constraint gate (SEC-01, issue #1141, adversarial review follow-up to
1527+ // #1210): the retried recommendations must ALSO satisfy the retrying
1528+ // session's execute:purchases permission Constraints (MaxPurchaseAmount,
1529+ // Providers, Services, Regions, AccountIDs), exactly as a fresh
1530+ // executePurchase call would enforce. Without this gate a retry-any
1531+ // session could replay another user's over-cap failed purchase, and a
1532+ // permission tightened after the original submission would never apply
1533+ // to a replay.
1534+ //
15261535// State gate:
15271536// - failedExec.Status must be "failed" → 409 otherwise.
15281537// - failedExec.Error must NOT match the persistent-failure map →
@@ -1548,6 +1557,18 @@ func (h *Handler) retryPurchase(ctx context.Context, req *events.LambdaFunctionU
15481557 return nil , err
15491558 }
15501559
1560+ // Enforce the per-permission Constraints (MaxPurchaseAmount, Providers,
1561+ // Services, Regions, AccountIDs) configured on the retrying session's
1562+ // execute:purchases permission (SEC-01, issue #1141), mirroring the gate
1563+ // in validateExecutePurchaseRequest. Without this, retry-any could
1564+ // relaunch another user's over-cap failed purchase that the retrying
1565+ // session's OWN constraints would deny, and constraints tightened after
1566+ // the original submission would never apply on replay (adversarial
1567+ // review follow-up to #1210).
1568+ if constraintErr := h .enforcePurchaseConstraints (ctx , session , failedExec .Recommendations ); constraintErr != nil {
1569+ return nil , constraintErr
1570+ }
1571+
15511572 newExecution , err := h .persistRetryExecution (ctx , failedExec , session , totalUpfront , totalSavings )
15521573 if err != nil {
15531574 return nil , err
@@ -1995,32 +2016,54 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event
19952016 // execute:purchases permission (SEC-01, issue #1141). Runs after the
19962017 // per-rec validation above so provider tokens are already normalized.
19972018 // Each recommendation must individually be granted by a permission; the
1998- // amount cap is checked against the batch's total upfront cost so it
1999- // cannot be evaded by splitting a large purchase across recs.
2000- if err := h .requirePermissionConstraints (ctx , session , "execute" , "purchases" , purchaseConstraintSets (execReq .Recommendations )); err != nil {
2019+ // amount cap is checked against the batch's total commitment (upfront
2020+ // plus recurring, see recTotalCommitment) so it cannot be evaded by
2021+ // splitting a large purchase across recs or by a no-upfront commitment
2022+ // whose real cost is entirely recurring.
2023+ if err := h .enforcePurchaseConstraints (ctx , session , execReq .Recommendations ); err != nil {
20012024 return ExecutePurchaseRequest {}, nil , err
20022025 }
20032026 return execReq , session , nil
20042027}
20052028
2029+ // enforcePurchaseConstraints builds the per-recommendation
2030+ // auth.PermissionConstraints sets (purchaseConstraintSets) and enforces them
2031+ // against session's execute:purchases permission, first rejecting a batch
2032+ // whose total commitment computes to zero (requireNonZeroCommitment).
2033+ // Shared by the direct-execute request validation and the retry path
2034+ // (retryPurchase) so both re-derive and check the exact same Constraints
2035+ // (SEC-01, issue #1141; adversarial review follow-up to #1210 -- the retry
2036+ // path previously skipped this check entirely).
2037+ func (h * Handler ) enforcePurchaseConstraints (ctx context.Context , session * Session , recs []config.RecommendationRecord ) error {
2038+ constraintSets := purchaseConstraintSets (recs )
2039+ if err := requireNonZeroCommitment (constraintSets ); err != nil {
2040+ return err
2041+ }
2042+ return h .requirePermissionConstraints (ctx , session , "execute" , "purchases" , constraintSets )
2043+ }
2044+
20062045// purchaseConstraintSets builds one auth.PermissionConstraints per
20072046// recommendation in a web execute request, for the SEC-01 constraint
20082047// enforcement in validateExecutePurchaseRequest. Single-value Provider/
20092048// Service/Region/AccountIDs lists make the auth service's any-overlap
20102049// matcher equivalent to strict containment, so a batch cannot pass on the
20112050// strength of one in-scope rec while another rec is out of scope.
2012- // MaxPurchaseAmount carries the batch's total upfront cost (the same basis
2013- // as the global $10M sanity cap in validateAndTotalRecommendations) on
2014- // every set. AccountIDs is ALWAYS populated: a rec without a CloudAccountID
2051+ // MaxPurchaseAmount carries the batch's TOTAL commitment (upfront cost plus
2052+ // the full recurring obligation over the term, see recTotalCommitment) on
2053+ // every set, not just the upfront cost. A no-upfront or partial-upfront
2054+ // commitment's UpfrontCost is legitimately $0 or small, so capping on
2055+ // upfront alone let a batch with an arbitrarily large recurring commitment
2056+ // evade the cap using entirely honest data (adversarial review follow-up
2057+ // to #1210). AccountIDs is ALWAYS populated: a rec without a CloudAccountID
20152058// carries unattributedAccountConstraint so an AccountIDs-constrained
20162059// permission denies it (the auth matcher treats an empty request-side list
20172060// as satisfied, so omitting the dimension would fail open). Session-level
20182061// allowed_accounts scoping is independently enforced by
20192062// validatePurchaseRecommendationScope.
20202063func purchaseConstraintSets (recs []config.RecommendationRecord ) []auth.PermissionConstraints {
2021- var totalUpfront float64
2064+ var totalCommitment float64
20222065 for i := range recs {
2023- totalUpfront += recs [i ]. UpfrontCost
2066+ totalCommitment += recTotalCommitment ( & recs [i ])
20242067 }
20252068 sets := make ([]auth.PermissionConstraints , 0 , len (recs ))
20262069 for i := range recs {
@@ -2034,12 +2077,52 @@ func purchaseConstraintSets(recs []config.RecommendationRecord) []auth.Permissio
20342077 Services : []string {rec .Service },
20352078 Regions : []string {rec .Region },
20362079 AccountIDs : []string {accountID },
2037- MaxPurchaseAmount : totalUpfront ,
2080+ MaxPurchaseAmount : totalCommitment ,
20382081 })
20392082 }
20402083 return sets
20412084}
20422085
2086+ // recTotalCommitment returns a single recommendation's full committed
2087+ // spend: the upfront cost plus the recurring monthly cost multiplied by
2088+ // the term length in months. UpfrontCost and MonthlyCost are both totals
2089+ // for the rec's Count (AWS Cost Explorer reports them per-batch, not
2090+ // per-instance -- see recommendationToOffering), so they sum directly
2091+ // with no per-instance scaling needed. rec.Term is in years (AWS/Azure/GCP
2092+ // RI/SP standard; see exchange_lookup.go), converted to months with the
2093+ // same *12 convention used throughout this package. A nil MonthlyCost
2094+ // (all-upfront commitments have no recurring charge) or a non-positive
2095+ // Term (can't be converted to months) contribute nothing to the recurring
2096+ // leg, matching the Term<=0 handling elsewhere in this package.
2097+ func recTotalCommitment (rec * config.RecommendationRecord ) float64 {
2098+ total := rec .UpfrontCost
2099+ if rec .Term > 0 && rec .MonthlyCost != nil {
2100+ total += * rec .MonthlyCost * float64 (rec .Term * 12 )
2101+ }
2102+ return total
2103+ }
2104+
2105+ // requireNonZeroCommitment guards against a purchase batch whose computed
2106+ // total commitment (see recTotalCommitment) is exactly zero even though it
2107+ // targets real, count>0 resources. A genuine RI/Savings Plan/commitment
2108+ // purchase always carries SOME committed spend -- either an upfront cost
2109+ // (all/partial-upfront) or a recurring monthly charge (no-upfront); a batch
2110+ // that totals to exactly $0 is not "unconstrained", it is malformed or
2111+ // adversarial data (e.g. a no-upfront rec submitted with monthly_cost
2112+ // omitted or zeroed out to slip under a MaxPurchaseAmount cap -- the same
2113+ // evasion shape recTotalCommitment was introduced to close). Per this
2114+ // repo's no-silent-fallback-on-money-paths convention, a zero total must
2115+ // fail loud rather than be silently treated as "no constraint applies"
2116+ // (MaxPurchaseAmount==0 reads as uncapped to matchPurchaseAmountConstraint).
2117+ // sets is assumed non-empty whenever recs is (callers already reject empty
2118+ // recommendation batches before reaching this check).
2119+ func requireNonZeroCommitment (sets []auth.PermissionConstraints ) error {
2120+ if len (sets ) > 0 && sets [0 ].MaxPurchaseAmount == 0 {
2121+ return NewClientError (400 , "recommendation batch has zero total commitment (no upfront or recurring cost); refusing to evaluate purchase constraints against it" )
2122+ }
2123+ return nil
2124+ }
2125+
20432126// normalizeCapacityPercent defaults an absent/zero capacity_percent to 100
20442127// and rejects anything outside [1, 100]. capacity_percent is audit-only but
20452128// still bounded: a value outside the range is a client bug worth surfacing
0 commit comments