Skip to content

Commit e7f9585

Browse files
committed
fix(auth): bind MaxPurchaseAmount to total commitment, enforce on retry
purchaseConstraintSets capped MaxPurchaseAmount against a batch's total upfront cost only. A no-upfront or partial-upfront commitment's real cost is the recurring monthly charge over the term, not the (possibly zero) upfront alone, so an honest no-upfront purchase could evade an otherwise-binding cap. Fix the basis to the batch's total commitment (upfront plus monthly_cost * term_months, recTotalCommitment) and reject a batch whose computed total is exactly zero rather than silently reading MaxPurchaseAmount==0 as "unconstrained" (requireNonZeroCommitment). Also reject a negative monthly_cost at the request boundary, since it would otherwise offset the recurring leg and reopen the same evasion. retryPurchase never consulted the execute:purchases permission Constraints at all, so a retry-any/retry-own session could replay another user's over-cap failed purchase, and a permission tightened after the original submission never applied to a replay. Route both the direct-execute and retry paths through a new shared enforcePurchaseConstraints helper so they check identical Constraints. Adversarial review follow-up to #1210 (SEC-01, issue #1141).
1 parent 9bfca74 commit e7f9585

4 files changed

Lines changed: 239 additions & 19 deletions

File tree

‎internal/api/handler_purchases.go‎

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
20202063
func 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

‎internal/api/handler_purchases_guards_test.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ func TestValidatePurchaseRecommendation(t *testing.T) {
9494
{"invalid term 0", mutate(func(r *config.RecommendationRecord) { r.Term = 0 }), true},
9595
{"invalid payment foo", mutate(func(r *config.RecommendationRecord) { r.Payment = "foo" }), true},
9696
{"negative count", mutate(func(r *config.RecommendationRecord) { r.Count = -1 }), true},
97+
{"negative monthly cost rejected", mutate(func(r *config.RecommendationRecord) {
98+
m := -1.0
99+
r.MonthlyCost = &m
100+
}), true},
101+
{"nil monthly cost accepted", mutate(func(r *config.RecommendationRecord) { r.MonthlyCost = nil }), false},
102+
{"zero monthly cost accepted", mutate(func(r *config.RecommendationRecord) {
103+
m := 0.0
104+
r.MonthlyCost = &m
105+
}), false},
97106
{"zero count", mutate(func(r *config.RecommendationRecord) { r.Count = 0 }), true},
98107
{"empty service", mutate(func(r *config.RecommendationRecord) { r.Service = "" }), true},
99108
{"empty provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "" }), true},

0 commit comments

Comments
 (0)