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
44 changes: 44 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/LeanerCloud/CUDly/internal/accounts"
"github.com/LeanerCloud/CUDly/internal/auth"
"github.com/LeanerCloud/CUDly/internal/config"
"github.com/LeanerCloud/CUDly/internal/credentials"
"github.com/LeanerCloud/CUDly/internal/email"
Expand Down Expand Up @@ -242,6 +243,49 @@ func (h *Handler) requirePermission(ctx context.Context, req *events.LambdaFunct
return session, nil
}

// unattributedAccountConstraint is the request-side AccountIDs value passed
// to requirePermissionConstraints when a request cannot be attributed to a
// registered cloud account (a recommendation without a cloud_account_id, or
// an RI exchange on a deployment whose running account is not registered).
// The auth matcher treats an EMPTY request-side list as "dimension not
// specified = satisfied", so omitting AccountIDs would let a permission
// constrained to specific accounts authorize an unattributed request
// (fail-open). Sending this sentinel keeps the list non-empty: it can never
// equal a real cloud account UUID, so a permission constrained to real
// accounts denies the request (fail closed), while a permission without an
// AccountIDs constraint still matches via the empty-permission-side rule.
const unattributedAccountConstraint = "unattributed"

// requirePermissionConstraints re-checks an already-authenticated session
// against request-derived permission constraint sets, so the Constraints
// (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) configured on
// the granting group permission are enforced at execution time (SEC-01,
// issue #1141). Callers must have passed requirePermission for the same
// action/resource first; this adds the constraint dimension once the request
// body is parsed and validated. The stateless admin API key is a full-access
// infrastructure credential with no user row, so it bypasses the check just
// like it bypasses requirePermission's per-user lookup. Fails closed on a
// missing auth service or a lookup error.
func (h *Handler) requirePermissionConstraints(ctx context.Context, session *Session, action, resource string, constraintSets []auth.PermissionConstraints) error {
if session == nil {
return fmt.Errorf("internal error: nil session passed to requirePermissionConstraints")
}
if session.UserID == apiKeyAdminUserID {
return nil
}
if h.auth == nil {
return fmt.Errorf("authentication service not configured")
}
has, err := h.auth.HasPermissionForConstraintsAPI(ctx, session.UserID, action, resource, constraintSets)
if err != nil {
return fmt.Errorf("permission constraint check failed: %w", err)
}
if !has {
return NewClientError(403, fmt.Sprintf("permission denied: this request exceeds the constraints configured on your %s permission for %s", action, resource))
}
return nil
}

// getAllowedAccounts returns the list of account IDs the user is allowed to
// access. Empty slice means all access (Administrators-group members carry the
// "*" wildcard, which GetAllowedAccountsAPI surfaces as unrestricted). The
Expand Down
4 changes: 4 additions & 0 deletions internal/api/handler_per_account_perms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ func scopedAuthMock(ctx context.Context) *MockAuthService {
// Grant every permission so role-gating doesn't interfere with what we
// actually want to test (account-level scoping).
m.On("HasPermissionAPI", ctx, permsScopedUserID, mock.Anything, mock.Anything).Return(true, nil)
// Likewise grant the SEC-01 execution-time constraint check; constraint
// behaviour has its own dedicated tests.
m.On("HasPermissionForConstraintsAPI", ctx, permsScopedUserID, mock.Anything, mock.Anything, mock.Anything).
Return(true, nil).Maybe()
m.On("GetAllowedAccountsAPI", ctx, permsScopedUserID).Return([]string{permsAccA}, nil)
return m
}
Expand Down
47 changes: 47 additions & 0 deletions internal/api/handler_purchases.go
Original file line number Diff line number Diff line change
Expand Up @@ -1548,9 +1548,56 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event
if err := validateCapacityConsistency(execReq.Recommendations, execReq.CapacityPercent); err != nil {
return ExecutePurchaseRequest{}, nil, err
}
// Enforce the per-permission Constraints (MaxPurchaseAmount, Providers,
// Services, Regions, AccountIDs) configured on the granting
// execute:purchases permission (SEC-01, issue #1141). Runs after the
// per-rec validation above so provider tokens are already normalized.
// Each recommendation must individually be granted by a permission; the
// amount cap is checked against the batch's total upfront cost so it
// cannot be evaded by splitting a large purchase across recs.
if err := h.requirePermissionConstraints(ctx, session, "execute", "purchases", purchaseConstraintSets(execReq.Recommendations)); err != nil {
return ExecutePurchaseRequest{}, nil, err
}
return execReq, session, nil
}

// purchaseConstraintSets builds one auth.PermissionConstraints per
// recommendation in a web execute request, for the SEC-01 constraint
// enforcement in validateExecutePurchaseRequest. Single-value Provider/
// Service/Region/AccountIDs lists make the auth service's any-overlap
// matcher equivalent to strict containment, so a batch cannot pass on the
// strength of one in-scope rec while another rec is out of scope.
// MaxPurchaseAmount carries the batch's total upfront cost (the same basis
// as the global $10M sanity cap in validateAndTotalRecommendations) on
// every set. AccountIDs is ALWAYS populated: a rec without a CloudAccountID
// carries unattributedAccountConstraint so an AccountIDs-constrained
// permission denies it (the auth matcher treats an empty request-side list
// as satisfied, so omitting the dimension would fail open). Session-level
// allowed_accounts scoping is independently enforced by
// validatePurchaseRecommendationScope.
func purchaseConstraintSets(recs []config.RecommendationRecord) []auth.PermissionConstraints {
var totalUpfront float64
for i := range recs {
totalUpfront += recs[i].UpfrontCost
}
sets := make([]auth.PermissionConstraints, 0, len(recs))
for i := range recs {
rec := &recs[i]
accountID := unattributedAccountConstraint
if rec.CloudAccountID != nil && *rec.CloudAccountID != "" {
accountID = *rec.CloudAccountID
}
sets = append(sets, auth.PermissionConstraints{
Providers: []string{rec.Provider},
Services: []string{rec.Service},
Regions: []string{rec.Region},
AccountIDs: []string{accountID},
MaxPurchaseAmount: totalUpfront,
})
}
return sets
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// normalizeCapacityPercent defaults an absent/zero capacity_percent to 100
// and rejects anything outside [1, 100]. capacity_percent is audit-only but
// still bounded: a value outside the range is a client bug worth surfacing
Expand Down
97 changes: 97 additions & 0 deletions internal/api/handler_purchases_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"testing"
"time"

"github.com/LeanerCloud/CUDly/internal/auth"
"github.com/LeanerCloud/CUDly/internal/config"
"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -3327,6 +3328,7 @@ func TestHandler_executePurchase_DirectExec_NoPermission(t *testing.T) {
// Base execute:purchases grant — passes the validateExecutePurchaseRequest
// gate but does not carry execute-any or execute-own for the direct path.
mockAuth.On("HasPermissionAPI", ctx, userSession.UserID, "execute", "purchases").Return(true, nil)
mockAuth.allowConstraintChecks()
mockAuth.On("HasPermissionAPI", ctx, userSession.UserID, "execute-any", "purchases").Return(false, nil)
mockAuth.On("HasPermissionAPI", ctx, userSession.UserID, "execute-own", "purchases").Return(false, nil)
// Scope check: no allowed_accounts restriction for this test.
Expand All @@ -3346,6 +3348,99 @@ func TestHandler_executePurchase_DirectExec_NoPermission(t *testing.T) {
assert.Contains(t, ce.Error(), "execute-any or execute-own")
}

// TestHandler_executePurchase_PermissionConstraintsDenied is the SEC-01
// (#1141) regression test: a session whose execute:purchases permission is
// granted (the bare verb/resource gate passes, exactly as it does for a
// constrained permission) but whose per-permission Constraints reject the
// request must receive a 403 BEFORE any execution is persisted. Pre-fix the
// handler never consulted the constraints, so this request was saved and
// proceeded to the approval flow.
func TestHandler_executePurchase_PermissionConstraintsDenied(t *testing.T) {
ctx := context.Background()
mockStore := new(MockConfigStore)
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
// No store expectations registered: the request must be rejected before
// SavePurchaseExecution / GetPendingExecutions are reached.
t.Cleanup(func() { mockStore.AssertExpectations(t) })

userSession := &Session{
UserID: "dddddddd-dddd-dddd-dddd-dddddddddddd",
Email: "capped@example.com",
}
mockAuth.On("ValidateSession", ctx, "capped-token").Return(userSession, nil)
// The bare verb/resource gate passes - this is exactly what happens for
// a permission that carries Constraints, because HasPermissionAPI checks
// with nil request-side constraints.
mockAuth.On("HasPermissionAPI", ctx, userSession.UserID, "execute", "purchases").Return(true, nil)
mockAuth.On("GetAllowedAccountsAPI", ctx, userSession.UserID).Return([]string{}, nil)
// The constraint check must receive one set per recommendation, each
// carrying the batch's TOTAL upfront cost ($3000 + $2500 = $5500) and
// that rec's provider/service/region as single-value lists. These recs
// carry no cloud_account_id, so AccountIDs must be the unattributed
// sentinel (never empty, which the auth matcher treats as satisfied).
mockAuth.On("HasPermissionForConstraintsAPI", ctx, userSession.UserID, "execute", "purchases",
mock.MatchedBy(func(sets []auth.PermissionConstraints) bool {
if len(sets) != 2 {
return false
}
for _, c := range sets {
if c.MaxPurchaseAmount != 5500.0 {
return false
}
if !assert.ObjectsAreEqual([]string{unattributedAccountConstraint}, c.AccountIDs) {
return false
}
}
return assert.ObjectsAreEqual([]string{"aws"}, sets[0].Providers) &&
assert.ObjectsAreEqual([]string{"ec2"}, sets[0].Services) &&
assert.ObjectsAreEqual([]string{"us-east-1"}, sets[0].Regions) &&
assert.ObjectsAreEqual([]string{"aws"}, sets[1].Providers) &&
assert.ObjectsAreEqual([]string{"ec2"}, sets[1].Services) &&
assert.ObjectsAreEqual([]string{"eu-west-1"}, sets[1].Regions)
})).Return(false, nil)

handler := &Handler{config: mockStore, auth: mockAuth}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer capped-token"},
Body: `{"recommendations": [
{"id": "rec-1", "provider": "aws", "service": "ec2", "region": "us-east-1", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": 3000.0, "savings": 50.0},
{"id": "rec-2", "provider": "aws", "service": "ec2", "region": "eu-west-1", "count": 2, "term": 1, "payment": "all-upfront", "upfront_cost": 2500.0, "savings": 100.0}
]}`,
}
_, err := handler.executePurchase(ctx, req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok, "expected a clientError, got: %v", err)
assert.Equal(t, 403, ce.code)
assert.Contains(t, ce.Error(), "constraints")
}

// TestPurchaseConstraintSets_AccountDimensionAlwaysPopulated pins the SEC-01
// fail-closed shape of the AccountIDs dimension: every constraint set must
// carry a non-empty AccountIDs list. The auth matcher treats an empty
// request-side list as "dimension not specified = satisfied", so a rec
// without a cloud_account_id must carry the unattributed sentinel; omitting
// the dimension would let an AccountIDs-constrained execute:purchases
// permission authorize an unattributed purchase (fail-open).
func TestPurchaseConstraintSets_AccountDimensionAlwaysPopulated(t *testing.T) {
attributed := "11111111-2222-3333-4444-555555555555"
empty := ""
sets := purchaseConstraintSets([]config.RecommendationRecord{
{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100, CloudAccountID: &attributed},
{Provider: "aws", Service: "rds", Region: "eu-west-1", UpfrontCost: 200, CloudAccountID: nil},
{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 300, CloudAccountID: &empty},
})
require.Len(t, sets, 3)
assert.Equal(t, []string{attributed}, sets[0].AccountIDs)
assert.Equal(t, []string{unattributedAccountConstraint}, sets[1].AccountIDs)
assert.Equal(t, []string{unattributedAccountConstraint}, sets[2].AccountIDs)
for i, c := range sets {
assert.NotEmpty(t, c.AccountIDs, "set %d must always carry the AccountIDs dimension", i)
assert.Equal(t, 600.0, c.MaxPurchaseAmount, "set %d must carry the batch total upfront", i)
}
}

// TestHandler_executePurchase_DirectExec_ExecuteAny verifies that a session
// with execute-any:purchases can direct-execute a purchase (no ownership
// check). The handler must call ApproveAndExecute and return status=completed.
Expand All @@ -3368,6 +3463,7 @@ func TestHandler_executePurchase_DirectExec_ExecuteAny(t *testing.T) {
// removed in issue #940 — HasPermissionAPI is now always consulted.
// First, the outer gate: requirePermission("execute","purchases").
mockAuth.On("HasPermissionAPI", ctx, adminSession.UserID, "execute", "purchases").Return(true, nil)
mockAuth.allowConstraintChecks()
// Then the direct-execute gate: authorizeSessionExecuteDirect("execute-any").
mockAuth.On("HasPermissionAPI", ctx, adminSession.UserID, "execute-any", "purchases").Return(true, nil)
// Scope check: no allowed_accounts restriction for this test.
Expand Down Expand Up @@ -3410,6 +3506,7 @@ func TestHandler_executePurchase_DirectExec_ExecuteOwn_Owner(t *testing.T) {
}
mockAuth.On("ValidateSession", ctx, "owner-token").Return(ownerSession, nil)
mockAuth.On("HasPermissionAPI", ctx, ownerID, "execute", "purchases").Return(true, nil)
mockAuth.allowConstraintChecks()
mockAuth.On("HasPermissionAPI", ctx, ownerID, "execute-any", "purchases").Return(false, nil)
mockAuth.On("HasPermissionAPI", ctx, ownerID, "execute-own", "purchases").Return(true, nil)
mockAuth.On("GetAllowedAccountsAPI", ctx, ownerID).Return([]string{}, nil)
Expand Down
32 changes: 31 additions & 1 deletion internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/LeanerCloud/CUDly/internal/auth"
"github.com/LeanerCloud/CUDly/internal/config"
"github.com/LeanerCloud/CUDly/internal/credentials"
"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/exchange"
"github.com/LeanerCloud/CUDly/pkg/logging"
awsprovider "github.com/LeanerCloud/CUDly/providers/aws"
Expand Down Expand Up @@ -716,7 +717,8 @@ func validateExecuteExchangeBody(body ExchangeExecuteRequestBody) error {
// Requires execute:ri-exchange (deliberately separate from execute:purchases)
// because RI exchanges are financially irreversible once submitted to AWS.
func (h *Handler) executeExchange(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
if _, err := h.requirePermission(ctx, req, "execute", "ri-exchange"); err != nil {
session, err := h.requirePermission(ctx, req, "execute", "ri-exchange")
if err != nil {
return nil, err
}

Expand All @@ -735,6 +737,34 @@ func (h *Handler) executeExchange(ctx context.Context, req *events.LambdaFunctio

region := body.Region

// Enforce the per-permission Constraints configured on the granting
// execute:ri-exchange permission (SEC-01, issue #1141). RI exchanges
// are AWS EC2 only and region-scoped, and operate on the RIs of the
// deployment's own AWS account, so AccountIDs carries the registered
// cloud account the running deployment resolves to (fail closed on a
// resolution error; unattributedAccountConstraint when the deployment
// maps to no registered account, so an AccountIDs-constrained
// permission denies). The amount cap is checked against the caller's
// max_payment_due_usd guardrail, which ExecuteExchange independently
// enforces against the actual quoted payment due.
maxPayment, _ := maxRat.Float64()
cloudAccountID, err := h.resolveReshapeCloudAccountID(ctx)
if err != nil {
return nil, fmt.Errorf("failed to resolve cloud account scope: %w", err)
}
if cloudAccountID == "" {
cloudAccountID = unattributedAccountConstraint
}
if err := h.requirePermissionConstraints(ctx, session, "execute", "ri-exchange", []auth.PermissionConstraints{{
AccountIDs: []string{cloudAccountID},
Providers: []string{string(common.ProviderAWS)},
Services: []string{string(common.ServiceEC2)},
Regions: []string{region},
MaxPurchaseAmount: maxPayment,
}}); err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return nil, err
}

exchangeID, quote, err := exchange.ExecuteExchange(ctx, exchange.ExchangeExecuteRequest{
Region: region,
ReservedIDs: body.RIIDs,
Expand Down
Loading
Loading