Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions internal/api/handler_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR
// ClientError(400) on a bad body or validation failure, which the store
// propagates unchanged; DB/transport errors surface as 500. A changed
// default reaches the service configs inside the same transaction.
var switchBefore bool
var switchBefore, ladderBefore bool
merged, err := h.config.UpdateGlobalConfigAtomic(ctx, func(existing *config.GlobalConfig) error {
// Snapshot before the wholesale unmarshal: this body writes onto the
// entire GlobalConfig, so ri_exchange_mode / ri_exchange_enabled reach
Expand All @@ -104,6 +104,7 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR
// write lands on.
before := riExchangeArmStateOf(existing)
switchBefore = existing.AutoPurchaseEnabled
ladderBefore = existing.LadderExecutionEnabled

// grace_period_days is a map: json.Unmarshal into a non-nil map MERGES
// keys (an omitted key can never be deleted). When the caller sends the
Expand All @@ -121,16 +122,26 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR
if gErr := h.requireRIExchangeAutoModeGrant(ctx, req, before, riExchangeArmStateOf(existing)); gErr != nil {
return gErr
}
return h.requireAutoPurchaseSwitchGrant(ctx, req, session, switchBefore, existing)
return h.requireSwitchGrants(ctx, req, session, switchBefore, ladderBefore, existing)
})
if err != nil {
return nil, err
}
logAutoPurchaseSwitchChange(ctx, session, switchBefore, merged)
logLadderExecutionSwitchChange(ctx, session, ladderBefore, merged)

return &StatusResponse{Status: "updated"}, nil
}

// requireSwitchGrants chains the arming gates of the two money-spending
// switches: auto-purchase, then ladder execution.
func (h *Handler) requireSwitchGrants(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, autoBefore, ladderBefore bool, cfg *config.GlobalConfig) error {
if err := h.requireAutoPurchaseSwitchGrant(ctx, req, session, autoBefore, cfg); err != nil {
return err
}
return h.requireLadderExecutionSwitchGrant(ctx, req, session, ladderBefore, cfg)
}

func (h *Handler) requireGlobalConfigScope(ctx context.Context, session *Session) error {
scope, err := h.getAccountScope(ctx, session)
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/api/handler_ladder.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ func (h *Handler) upsertLadderConfig(ctx context.Context, req *events.LambdaFunc
return nil, err
}

if err := h.gateLadderConfigWrite(ctx, req, session, &cfg); err != nil {
return nil, err
}

result, err := h.config.UpsertLadderConfig(ctx, &cfg)
if err != nil {
return nil, fmt.Errorf("failed to upsert ladder config: %w", err)
Expand Down
9 changes: 6 additions & 3 deletions internal/api/handler_ladder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func newLadderHandler(t *testing.T) (*Handler, *MockConfigStore, **config.Ladder
// .Maybe() so the 400-path test (store never reached) doesn't trip
// AssertExpectations on an unmet expectation. The success tests prove the
// store was reached by asserting *captured is non-nil (set only in Run).
mockStore.On("GetLadderConfig", ctx, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil).Maybe()
mockStore.On("UpsertLadderConfig", ctx, mock.AnythingOfType("*config.LadderConfigDB")).
Run(func(args mock.Arguments) {
captured = args.Get(1).(*config.LadderConfigDB)
Expand Down Expand Up @@ -213,10 +214,10 @@ func ladderScopedReq(body string) *events.LambdaFunctionURLRequest {
}

// ladderScopedBody builds a valid ladder config body targeting accountID.
// mode=auto_approve with max_hourly_commit_per_run omitted (nil => no cap) is
// the shape from the issue's failure scenario.
// The config is disabled so these scope tests stay independent of the arming
// grant (covered in ladder_arming_grant_test.go).
func ladderScopedBody(accountID string) string {
return `{"cloud_account_id":"` + accountID + `","provider":"aws","enabled":true,` +
return `{"cloud_account_id":"` + accountID + `","provider":"aws","enabled":false,` +
`"mode":"auto_approve","cadence":"daily",` + ladderValidRamp + `}`
}

Expand All @@ -230,6 +231,7 @@ func ladderScopedHandler(t *testing.T) (*Handler, *MockConfigStore) {
Return(&config.CloudAccount{ID: scopedInAccount, Name: "in-scope", Provider: "aws"}, nil).Maybe()
mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount).
Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil).Maybe()
mockStore.On("GetLadderConfig", mock.Anything, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil).Maybe()
return h, mockStore
}

Expand Down Expand Up @@ -272,6 +274,7 @@ func TestUpsertLadderConfig_UnrestrictedSessionUnaffected(t *testing.T) {
handler, mockStore := scopedHandler(t) // no accounts => grantAdmin => unrestricted
mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount).
Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil)
mockStore.On("GetLadderConfig", mock.Anything, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil)
mockStore.On("UpsertLadderConfig", mock.Anything, mock.AnythingOfType("*config.LadderConfigDB")).
Return(&config.LadderConfigDB{ID: "written-row", CloudAccountID: scopedOutAccount}, nil)

Expand Down
126 changes: 126 additions & 0 deletions internal/api/ladder_arming_grant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package api

import (
"context"
"fmt"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
"github.com/LeanerCloud/cloud-commitments-go/pkg/logging"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/aws/aws-lambda-go/events"
)

// Ladder execution arming is a money-spending authorization, gated like the
// auto-purchase switch (auto_purchase_grant.go): execute:purchases for a real
// user, never the admin API key. Plain LadderingEnabled (planning only) and
// every de-escalation (switch off, disarm, lower the cap) stay ordinary
// update:config writes.

// requireLadderExecutionSwitchGrant gates and stamps a change of
// GlobalConfig.LadderExecutionEnabled inside the UpdateGlobalConfigAtomic
// closure. Only off->on needs the grant; any change is stamped.
func (h *Handler) requireLadderExecutionSwitchGrant(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, before bool, cfg *config.GlobalConfig) error {
if before == cfg.LadderExecutionEnabled {
return nil
}
if cfg.LadderExecutionEnabled {
if err := h.requireAutoPurchaseGrant(ctx, req, session); err != nil {
return err
}
}
by, via, at := session.UserID, credentialKind(ctx, session), time.Now().UTC()
cfg.LadderExecutionChangedBy, cfg.LadderExecutionChangedVia, cfg.LadderExecutionChangedAt = &by, &via, &at
return nil
}

// logLadderExecutionSwitchChange records a committed change of the switch.
func logLadderExecutionSwitchChange(ctx context.Context, session *Session, before bool, after *config.GlobalConfig) {
if after == nil || after.LadderExecutionEnabled == before {
return
}
logging.Infof("ladder execution switch changed: actor=%s via=%s %t -> %t", session.UserID, credentialKind(ctx, session), before, after.LadderExecutionEnabled)
}

// ladderConfigArmed reports whether the config, with the global switches on,
// would buy without an approver: enabled and in auto-approve mode.
func ladderConfigArmed(c *config.LadderConfigDB) bool {
return c != nil && c.Enabled && c.Mode == string(ladder.ModeAutoApprove)
}

// ladderCapRaised reports whether after allows more spend than before. A nil
// cap means no cap, so clearing a cap is a raise. Caps reaching the gate have
// passed Validate; an unrepresentable one fails closed as the maximum.
func ladderCapRaised(before, after *float64) bool {
if after == nil {
return before != nil
}
if before == nil {
return false
}
return ladderCapMicros(after) > ladderCapMicros(before)
}

func ladderCapMicros(v *float64) int64 {
n, err := config.LadderCapMicros(*v)
if err != nil {
return 1<<63 - 1
}
return n
}

// ladderConfigArmEscalates reports whether the write takes the config from
// not-armed to armed, or raises or clears the cap of an armed config. before
// is nil when no row exists.
func ladderConfigArmEscalates(before, after *config.LadderConfigDB) bool {
if !ladderConfigArmed(after) {
return false
}
if !ladderConfigArmed(before) {
return true
}
return ladderCapRaised(before.MaxHourlyCommitPerRun, after.MaxHourlyCommitPerRun)
}

// requireLadderConfigArmGrant gates a ladder_config write and logs the
// authorized escalation. before is nil when no row exists.
func (h *Handler) requireLadderConfigArmGrant(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, before, after *config.LadderConfigDB) error {
if !ladderConfigArmEscalates(before, after) {
return nil
}
if err := h.requireAutoPurchaseGrant(ctx, req, session); err != nil {
return err
}
var oldCap *float64
if before != nil {
oldCap = before.MaxHourlyCommitPerRun
}
logging.Infof("ladder config arm or cap raise authorized (write follows): account=%s provider=%s config=%q actor=%s via=%s armed %t -> true cap %s -> %s",
after.CloudAccountID, after.Provider, ladderConfigID(before), session.UserID, credentialKind(ctx, session),
ladderConfigArmed(before), formatLadderCap(oldCap), formatLadderCap(after.MaxHourlyCommitPerRun))
return nil
}

// gateLadderConfigWrite loads the stored row and applies the arming gate. A
// lookup failure refuses the write (fail closed).
func (h *Handler) gateLadderConfigWrite(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, after *config.LadderConfigDB) error {
before, err := h.config.GetLadderConfig(ctx, after.CloudAccountID, after.Provider)
if err != nil {
return fmt.Errorf("failed to load ladder config: %w", err)
}
return h.requireLadderConfigArmGrant(ctx, req, session, before, after)
}

func ladderConfigID(c *config.LadderConfigDB) string {
if c == nil {
return ""
}
return c.ID
}

func formatLadderCap(v *float64) string {
if v == nil {
return "NULL"
}
return fmt.Sprintf("%.6f", *v)
}
Loading
Loading