diff --git a/internal/api/grantadmin_carveout_test.go b/internal/api/grantadmin_carveout_test.go index 5b1ac9cc1..19215ceac 100644 --- a/internal/api/grantadmin_carveout_test.go +++ b/internal/api/grantadmin_carveout_test.go @@ -332,7 +332,7 @@ func TestRequirePermissionConstraints_ConstrainedGrantIsBoundedAtHandler(t *test session := &Session{UserID: "u1"} require.NoError(t, - h.requirePermissionConstraints(ctx, session, auth.ResourceRIExchange, []auth.PermissionConstraints{{ + h.requirePermissionConstraints(ctx, session, auth.ActionExecute, auth.ResourceRIExchange, []auth.PermissionConstraints{{ Providers: []string{"aws"}, MaxPurchaseAmount: 500, }}), "a request inside every dimension must pass the gate") @@ -345,7 +345,7 @@ func TestRequirePermissionConstraints_ConstrainedGrantIsBoundedAtHandler(t *test {"over the spend cap", auth.PermissionConstraints{Providers: []string{"aws"}, MaxPurchaseAmount: 5000}}, } { t.Run(tc.name, func(t *testing.T) { - err := h.requirePermissionConstraints(ctx, session, auth.ResourceRIExchange, + err := h.requirePermissionConstraints(ctx, session, auth.ActionExecute, auth.ResourceRIExchange, []auth.PermissionConstraints{tc.request}) require.Error(t, err) ce, ok := IsClientError(err) diff --git a/internal/api/handler.go b/internal/api/handler.go index a3c0ae379..52bac12cb 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -465,25 +465,24 @@ func (h *Handler) authorizeAPIKeyAny(ctx context.Context, apiKey string, verbs [ // AccountIDs constraint still matches via the empty-permission-side rule. const unattributedAccountConstraint = "unattributed" -// requirePermissionConstraintsAction is the action every current caller of -// requirePermissionConstraints checks (execute:purchases, execute:ri-exchange -// for both AWS and Azure). Every constraint-gated operation today is an -// irreversible execute; hardcoded rather than threaded as a parameter since -// a parameter with only one real value across all call sites is dead -// flexibility (a genuinely new action should add a real parameter back, -// not resurrect an unused one). -const requirePermissionConstraintsAction = "execute" - // 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. +// issue #1141). Callers must have passed requirePermission (or an equivalent +// verb gate) 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. +// +// action is threaded explicitly (not hardcoded to "execute") because issue +// #60 added callers on the approve-any/approve-own and execute-any/ +// execute-own verbs, which are distinct permissions from the base +// execute:purchases permission the original SEC-01 fix covered: a session +// can hold execute:purchases with one set of Constraints and execute-any: +// purchases with a different (or absent) set, so the constraint lookup must +// match the verb that actually authorized the mutation. // // For user-API-key sessions (session.UserAPIKeyID != ""), constraints are // evaluated against the KEY's effective permissions (the intersection of the @@ -491,7 +490,7 @@ const requirePermissionConstraintsAction = "execute" // prevents a CI key with MaxPurchaseAmount=$100 from spending up to the // owning user's full group limit by inheriting the broader group permissions // (adversarial-review F2). -func (h *Handler) requirePermissionConstraints(ctx context.Context, session *Session, resource string, constraintSets []auth.PermissionConstraints) 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") } @@ -504,21 +503,21 @@ func (h *Handler) requirePermissionConstraints(ctx context.Context, session *Ses // User API key: evaluate constraints against the key's effective permissions, // not the owning user's full group permissions. if session.UserAPIKeyID != "" { - has, err := h.auth.HasAPIKeyPermissionForConstraintsAPI(ctx, session.UserAPIKeyID, session.UserID, requirePermissionConstraintsAction, resource, constraintSets) + has, err := h.auth.HasAPIKeyPermissionForConstraintsAPI(ctx, session.UserAPIKeyID, 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", requirePermissionConstraintsAction, resource)) + return NewClientError(403, fmt.Sprintf("permission denied: this request exceeds the constraints configured on your %s permission for %s", action, resource)) } return nil } - has, err := h.auth.HasPermissionForConstraintsAPI(ctx, session.UserID, requirePermissionConstraintsAction, resource, constraintSets) + 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", requirePermissionConstraintsAction, resource)) + return NewClientError(403, fmt.Sprintf("permission denied: this request exceeds the constraints configured on your %s permission for %s", action, resource)) } return nil } diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index df8957e3a..dcfeae28d 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -324,6 +324,30 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc return nil, err } + // Re-hydrate the persisted recommendations and enforce the session's + // execute:purchases Constraints against them before RunPlannedPurchaseNow + // fires the purchase (issue #60). requirePermission above only checked + // the bare execute:purchases verb: a session running a plan via + // update-any:purchases (which authorizeExecutionManagement lets bypass + // the creator check) would otherwise bypass any MaxPurchaseAmount/ + // Providers/Services/Regions/AccountIDs cap configured on their own + // execute:purchases permission, exactly the fail-open class the web + // execute path (validateExecutePurchaseRequest) already closed for + // SEC-01 (#1141). Routed through the shared enforcePurchaseConstraints + // helper so a planned execution whose recs sum to $0 is refused by + // requireNonZeroCommitment rather than reading as an uncapped + // MaxPurchaseAmount (issue #60 review follow-up). + execution, fetchErr := h.config.GetExecutionByID(ctx, executionID) + if errors.Is(fetchErr, config.ErrNotFound) { + return nil, errNotFound + } + if fetchErr != nil { + return nil, fmt.Errorf("failed to get execution: %w", fetchErr) + } + if constraintErr := h.enforcePurchaseConstraints(ctx, session, "execute", execution.Recommendations); constraintErr != nil { + return nil, constraintErr + } + if err := h.purchase.RunPlannedPurchaseNow(ctx, executionID, fourEyesActorIdentity(session), resolveCreatorUserID(session)); err != nil { return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be started: %v", executionID, err)) } @@ -607,6 +631,21 @@ func (h *Handler) approveViaToken(ctx context.Context, req *events.LambdaFunctio if err := h.requireDifferentApprover(ctx, tokenSession, execution); err != nil { return nil, err } + // SEC-01 follow-up (issue #60 review): the deep-link flow always forces + // a login, so tokenSession is populated whenever a browser reaches this + // endpoint. approvePurchase's session-vs-token dispatch already denies a + // session whose approve-any/approve-own is capped tighter than this + // request before it ever reaches approveViaToken (authorizeSessionApprove + // returns a terminal constraintDeniedError, not a fall-through 403). This + // is a second, independent evaluation of that same session's Constraints + // so a capped approver who is ALSO the account's registered + // contact_email cannot use the email link to approve past their own cap + // -- the contact_email gate above authorizes a completely different + // principal (the account's contact), and must not be read as clearing + // Constraints on this session's own approve-* permission. + if err := h.approveConstraintsForSession(ctx, tokenSession, execution); err != nil { + return nil, err + } // Check for Gmail-style pre-fire delay (issue #291 wave-2). // Token/email-link path: no authenticated session UUID is available, so the // scheduled transition is recorded as system-initiated (transitioned_by = NULL). @@ -745,6 +784,33 @@ func (h *Handler) approvePurchaseViaSession(ctx context.Context, req *events.Lam // approve the given execution under the approve-any / approve-own RBAC // rules added in issue #286. Returns a 403 ClientError otherwise. // Mirror of authorizeSessionCancel. +// +// Also enforces the approve-any/approve-own permission's own per-permission +// Constraints against execution.Recommendations (issue #60): approving here +// hands off to purchase.Manager.ApproveAndExecute, which fires the AWS +// purchase synchronously, so this is as much a money-spending path as +// execute-any/execute-own and must be capped the same way. Without this, a +// session whose approve-any is constrained to e.g. MaxPurchaseAmount: $1,000 +// could approve (and thereby execute) an arbitrarily large pending execution +// created by someone else, even though the identically-capped execute-any +// permission already blocks a matching direct-execute. +// +// A session holding BOTH approve-any and approve-own is evaluated on +// approve-any ONLY (the first branch below returns unconditionally once +// hasAny is true): approve-any's Constraints are what gate it, even for an +// execution this session happens to have created itself. This mirrors +// authorizeSessionExecuteDirect's execute-any/execute-own ordering and the +// group model's own semantics -- approve-any is the broader grant, so a +// group holding both is expected to configure whichever Constraints the +// broader grant should carry, not rely on approve-own's (narrower-intent) +// Constraints ever being consulted for such a session. +// +// The returned error is wrapped via wrapConstraintDenied whenever it comes +// from the Constraints check (as opposed to the bare verb/ownership checks +// above it): approvePurchase's session-vs-token dispatch must treat a +// Constraints-exceeded denial as terminal, not as a signal to fall through +// to the token/contact_email flow, which never evaluates Constraints (issue +// #60 review follow-up; see constraintDeniedError's doc comment). func (h *Handler) authorizeSessionApprove(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { // The stateless admin API key has full access and no user row to resolve // permissions from. Administrators-group users fall through and pass via @@ -762,7 +828,7 @@ func (h *Handler) authorizeSessionApprove(ctx context.Context, session *Session, return fmt.Errorf("permission check failed: %w", err) } if hasAny { - return nil + return wrapConstraintDenied(h.enforcePurchaseConstraints(ctx, session, auth.ActionApproveAny, execution.Recommendations)) } hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases) @@ -776,6 +842,41 @@ func (h *Handler) authorizeSessionApprove(ctx context.Context, session *Session, if execution.CreatedByUserID == nil || *execution.CreatedByUserID != session.UserID { return NewClientError(403, "permission denied: cannot approve another user's pending purchase") } + return wrapConstraintDenied(h.enforcePurchaseConstraints(ctx, session, auth.ActionApproveOwn, execution.Recommendations)) +} + +// approveConstraintsForSession is approveViaToken's independent Constraints +// check (issue #60 review follow-up): it mirrors authorizeSessionApprove's +// approve-any/approve-own grant-then-constrain matrix, but returns nil +// (rather than a 403) when the session holds neither verb, or holds only +// approve-own for an execution it didn't create. Those are not denials here +// -- a session with no applicable approve-* permission simply has no +// Constraints to be bound by, and is being authorized entirely through the +// token flow's separate contact_email mechanism. This function only ever +// narrows what that mechanism would otherwise allow; it never widens it. +// +// session may be nil (a pure email-client click with no browser session at +// all); that also returns nil, since there is no session-held permission to +// evaluate. session.UserID == apiKeyAdminUserID or a nil h.auth are treated +// the same way authorizeSessionApprove treats them. +func (h *Handler) approveConstraintsForSession(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { + if session == nil || session.UserID == apiKeyAdminUserID || h.auth == nil { + return nil + } + hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveAny, auth.ResourcePurchases) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if hasAny { + return h.enforcePurchaseConstraints(ctx, session, auth.ActionApproveAny, execution.Recommendations) + } + hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if hasOwn && execution.CreatedByUserID != nil && *execution.CreatedByUserID == session.UserID { + return h.enforcePurchaseConstraints(ctx, session, auth.ActionApproveOwn, execution.Recommendations) + } return nil } @@ -984,16 +1085,25 @@ func (h *Handler) requireDifferentApprover(ctx context.Context, session *Session // // creatorID is the creator of the execution being submitted (resolved via // resolveCreatorUserID before this call; "" on non-human or legacy rows). +// recs is the execution's (store-priced) recommendation set, used to build +// the same per-permission Constraints sets validateExecutePurchaseRequest +// already enforces against the base execute:purchases permission (issue +// #60): execute-any/execute-own are DISTINCT permissions from execute, so a +// session capped tighter on execute-any than on execute could otherwise +// direct-execute past that tighter cap by holding both verbs. // // Gate logic (mirrors authorizeSessionApprove / authorizeSessionCancel): // - stateless admin API key: always permitted (apiKeyAdminUserID sentinel). -// - execute-any: permitted regardless of creator. Administrators-group users -// pass here because {admin, *} matches ActionExecuteAny. -// - execute-own: permitted only when creatorID == session.UserID and both -// are non-empty (prevents an empty-string collision from granting access). -// - no matching grant: 403 fail-closed; nil auth component is a 500 as -// per feedback_fail_closed_middleware.md. -func (h *Handler) authorizeSessionExecuteDirect(ctx context.Context, session *Session, creatorID string) error { +// - execute-any: permitted when the execute-any permission's own +// Constraints also allow this batch, regardless of creator. +// Administrators-group users pass the verb check here because +// {admin, *} matches ActionExecuteAny. +// - execute-own: permitted only when creatorID == session.UserID (both +// non-empty) AND the execute-own permission's own Constraints allow +// this batch. +// - no matching grant, or a constraint denial: 403 fail-closed; nil auth +// component is a 500 as per feedback_fail_closed_middleware.md. +func (h *Handler) authorizeSessionExecuteDirect(ctx context.Context, session *Session, creatorID string, recs []config.RecommendationRecord) error { // Stateless admin API key: full access, no user row. Administrators-group // users pass via the execute-any HasPermissionAPI check below, since // {admin, *} matches any requested permission. @@ -1009,7 +1119,7 @@ func (h *Handler) authorizeSessionExecuteDirect(ctx context.Context, session *Se return fmt.Errorf("permission check failed: %w", err) } if hasAny { - return nil + return h.enforcePurchaseConstraints(ctx, session, auth.ActionExecuteAny, recs) } hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionExecuteOwn, auth.ResourcePurchases) @@ -1025,7 +1135,7 @@ func (h *Handler) authorizeSessionExecuteDirect(ctx context.Context, session *Se if session.UserID == "" || creatorID == "" || creatorID != session.UserID { return NewClientError(403, "permission denied: execute-own requires you to be the creator of this purchase") } - return nil + return h.enforcePurchaseConstraints(ctx, session, auth.ActionExecuteOwn, recs) } func (h *Handler) cancelPurchase(ctx context.Context, req *events.LambdaFunctionURLRequest, execID, token string) (any, error) { @@ -1637,7 +1747,7 @@ func (h *Handler) retryPurchase(ctx context.Context, req *events.LambdaFunctionU // store-derived prices stamped at submit time // (priceRecommendationsFromStore), so this re-check never sees // client-supplied numbers. - if constraintErr := h.enforcePurchaseConstraints(ctx, session, failedExec.Recommendations); constraintErr != nil { + if constraintErr := h.enforcePurchaseConstraints(ctx, session, "execute", failedExec.Recommendations); constraintErr != nil { return nil, constraintErr } @@ -2005,6 +2115,44 @@ func (h *Handler) tryResolveActorEmail(ctx context.Context, req *events.LambdaFu return "" } +// constraintDeniedError marks a 403 produced by requirePermissionConstraints +// (a granted verb whose own per-permission Constraints reject the request) +// so isPermissionDenied's strict, unwrapped *clientError assertion does NOT +// recognize it (issue #60 review follow-up, same mechanism as errCSRFRejected +// above). This matters because approvePurchase's session-vs-token dispatch +// treats any isPermissionDenied 403 as "no approve-* grant at all, fall +// through to the token/contact_email flow" -- correct for a bare missing-verb +// denial, but wrong for a Constraints-exceeded denial: approveViaToken never +// evaluates Constraints, so a session holding a capped approve-any/ +// approve-own would otherwise bypass its own cap by using the email +// deep-link (which forces a login and therefore always carries a session) +// instead of the dashboard Approve button. Wrapping forces the dispatch's +// `default:` branch (propagate, terminal) instead of the fall-through +// branch. IsClientError still resolves this to the right HTTP code and +// message via errors.As + Unwrap, so response mapping is unaffected. +type constraintDeniedError struct { + *clientError +} + +func (e *constraintDeniedError) Unwrap() error { return e.clientError } + +// wrapConstraintDenied marks a non-nil requirePermissionConstraints / +// enforcePurchaseConstraints error as terminal (see constraintDeniedError). +// A nil err, or an err that isn't a *clientError (e.g. an auth-service +// failure wrapped with fmt.Errorf), passes through unchanged -- the latter +// is already un-recognized by isPermissionDenied's own strict assertion, so +// it already hits the propagate branch without needing to be wrapped here. +func wrapConstraintDenied(err error) error { + if err == nil { + return nil + } + ce, ok := err.(*clientError) //nolint:errorlint // wrapping the concrete constructor's return; see constraintDeniedError doc + if !ok { + return err + } + return &constraintDeniedError{clientError: ce} +} + // isPermissionDenied reports whether err is *directly* a 403 ClientError // (not merely something that wraps one). Used by the cancel-from-email // session pre-check to distinguish a legitimate "your session lacks @@ -2018,7 +2166,8 @@ func (h *Handler) tryResolveActorEmail(ctx context.Context, req *events.LambdaFu // wrapper's intent), not "this is still a 403". errors.As-style unwrapping // would erase that distinction and silently route wrapped backend // failures into the contact_email gate — exactly the misclassification -// the propagate-vs-fall-through split is meant to prevent. +// the propagate-vs-fall-through split is meant to prevent. constraintDeniedError +// above deliberately exploits the same strictness from the other direction. func isPermissionDenied(err error) bool { ce, ok := err.(*clientError) //nolint:errorlint // strict (unwrapped) assertion is deliberate; see comment above return ok && ce.code == 403 @@ -2202,21 +2351,28 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event // enforcePurchaseConstraints builds the per-recommendation // auth.PermissionConstraints sets (purchaseConstraintSets) and enforces them -// against session's execute:purchases permission, first rejecting a batch +// against session's action:purchases permission, first rejecting a batch // whose total commitment computes to zero (requireNonZeroCommitment). -// Shared by the direct-execute request validation and the retry path -// (retryPurchase) so both re-derive and check the exact same Constraints -// (SEC-01, issue #1141; adversarial review follow-up to #1210 -- the retry -// path previously skipped this check entirely). The web execute path -// reaches this through priceAndEnforcePurchaseConstraints, so recs carry -// store-derived costs; the retry path enforces against the persisted row, -// which was priced when it was written. -func (h *Handler) enforcePurchaseConstraints(ctx context.Context, session *Session, recs []config.RecommendationRecord) error { +// Shared by every money-mutating purchases call site (direct-execute request +// validation, the retry path, execute-any/own, approve-any/own and +// runPlannedPurchase) so all of them re-derive and check the exact same +// Constraints against the exact same zero-commitment guard (SEC-01, issue +// #1141; adversarial review follow-up to #1210 -- the retry path previously +// skipped this check entirely; issue #60 review follow-up -- approve-any/own +// and runPlannedPurchase previously skipped requireNonZeroCommitment, so an +// execution whose recs summed to $0 read as an uncapped MaxPurchaseAmount). +// action is threaded through (not hardcoded) because callers check +// different verbs: "execute" for the web submit and retry paths and +// runPlannedPurchase, "execute-any"/"execute-own" for direct-execute, +// "approve-any"/"approve-own" for session approve. recs carry store-derived +// or persisted-at-submit-time costs depending on the caller; none of these +// call sites ever price from client-supplied numbers. +func (h *Handler) enforcePurchaseConstraints(ctx context.Context, session *Session, action string, recs []config.RecommendationRecord) error { constraintSets := purchaseConstraintSets(recs) if err := requireNonZeroCommitment(constraintSets); err != nil { return err } - return h.requirePermissionConstraints(ctx, session, "purchases", constraintSets) + return h.requirePermissionConstraints(ctx, session, action, "purchases", constraintSets) } // purchaseConstraintSets builds one auth.PermissionConstraints per @@ -2680,7 +2836,7 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio // prevents a client that sets execute_mode="direct" but only holds the // base execute:purchases verb from silently degrading to the email flow. if execReq.ExecuteMode == "direct" { - if err := h.authorizeSessionExecuteDirect(ctx, session, creatorID); err != nil { + if err := h.authorizeSessionExecuteDirect(ctx, session, creatorID, execReq.Recommendations); err != nil { return nil, err } return h.directExecutePurchase(ctx, req, execution, session, paymentAdjustments) diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 2009b7a5a..1a704260e 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -22,6 +22,13 @@ import ( // to satisfy the post-hardening approver-set policy (see // authorizeApprovalAction): the global notify mailbox is no longer an // authorized approver, so tests must wire a per-account contact email. +// +// The recommendation carries a non-zero UpfrontCost: authorizeSessionApprove +// / approveConstraintsForSession route through enforcePurchaseConstraints +// (issue #60 review follow-up), which refuses a batch whose total commitment +// computes to $0 (requireNonZeroCommitment) before ever consulting +// Constraints -- a $0-cost fixture would hit that guard on the session- +// approve path, not the specific behavior each test exercises. func approvalTestExec(execID, contact string, mockConfig *MockConfigStore) *config.PurchaseExecution { accountID := "acct-1" exec := &config.PurchaseExecution{ @@ -29,7 +36,7 @@ func approvalTestExec(execID, contact string, mockConfig *MockConfigStore) *conf ApprovalToken: "valid-token", Status: "pending", Recommendations: []config.RecommendationRecord{ - {ID: "r1", CloudAccountID: &accountID}, + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", CloudAccountID: &accountID, UpfrontCost: 100}, }, } mockConfig.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { @@ -293,8 +300,12 @@ func TestHandler_approvePurchase_SessionApproveAnyChainsToExecute(t *testing.T) ExecutionID: execID, ApprovalToken: "valid-token", Status: "pending", + // UpfrontCost non-zero: authorizeSessionApprove now routes through + // enforcePurchaseConstraints, which refuses a $0-commitment batch + // before this test's approve-any flow is even exercised (issue #60 + // review follow-up). Recommendations: []config.RecommendationRecord{ - {ID: "r1"}, + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, }, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) @@ -344,10 +355,13 @@ func TestHandler_approvePurchase_SessionExecuteFailureSurfacesAs409(t *testing.T mockConfig := new(MockConfigStore) exec := &config.PurchaseExecution{ - ExecutionID: execID, - ApprovalToken: "valid-token", - Status: "pending", - Recommendations: []config.RecommendationRecord{{ID: "r1"}}, + ExecutionID: execID, + ApprovalToken: "valid-token", + Status: "pending", + // Non-zero UpfrontCost: see the comment on the sibling test above. + Recommendations: []config.RecommendationRecord{ + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, + }, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) // approvePurchaseViaSession checks PurchaseDelayHours (issue #291 wave-2) @@ -376,6 +390,174 @@ func TestHandler_approvePurchase_SessionExecuteFailureSurfacesAs409(t *testing.T assert.Contains(t, ce.Error(), "could not be approved") } +// TestHandler_approvePurchase_SessionApproveAny_PermissionConstraintsDenied +// is the SEC-01 (#1141) follow-up regression test for issue #60: a session +// whose approve-any:purchases verb is granted (the bare-verb gate passes, +// exactly as it does for a permission that carries Constraints) but whose +// per-permission Constraints reject the request must receive a 403 BEFORE +// ApproveAndExecute fires the AWS purchase. Pre-fix, authorizeSessionApprove +// never consulted the approve-any permission's own Constraints, so a +// $50,000 execution created by someone else was approved (and executed) by +// a session whose approve-any was configured with a $1,000 cap. +// +// This is also the terminal-dispatch regression test from the #60 review: +// this call uses an EMPTY token, which pre-#60-review would reach +// approvePurchaseViaSession/its CSRF check either way, masking whether the +// constraint denial itself was terminal. The absence of ANY CSRF (or other) +// mock beyond the two RBAC ones registered below is what proves it: if +// isPermissionDenied ever again started recognizing a wrapped +// constraintDeniedError, this test would panic on an unexpected call +// instead of silently reading a different error message. +func TestHandler_approvePurchase_SessionApproveAny_PermissionConstraintsDenied(t *testing.T) { + ctx := context.Background() + execID := "12345678-1234-1234-1234-123456789abc" + otherCreator := "creator-user-uuid" + + mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: "valid-token", + Status: "pending", + CreatedByUserID: &otherCreator, + Recommendations: []config.RecommendationRecord{ + {Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000}, + }, + } + mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) + + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + userSession := &Session{UserID: "dddddddd-dddd-dddd-dddd-dddddddddddd", Email: "capped@example.com"} + mockAuth.On("ValidateSession", ctx, "sess-tok").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, "approve-any", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, userSession.UserID, "approve-any", "purchases", + purchaseConstraintSets(exec.Recommendations)).Return(false, nil) + // authorizeSessionApprove wraps the constraint denial in + // constraintDeniedError (issue #60 review follow-up), so + // approvePurchase's session-vs-token dispatch treats it as terminal: the + // `default:` branch returns it directly instead of falling through to + // approvePurchaseViaSession's CSRF check / a second RBAC pass, or to the + // token/contact_email flow. No ValidateCSRFToken (or any other) mock is + // registered beyond the two above, and mockAuth.AssertExpectations in + // t.Cleanup would fail this test if the dispatch reached either path. + + mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) + + handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"authorization": "Bearer sess-tok"}} + _, err := handler.approvePurchase(ctx, req, execID, "") + 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") + // The purchase must never fire. + mockPurchase.AssertNotCalled(t, "ApproveAndExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockPurchase.AssertNotCalled(t, "ApproveExecution", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// TestHandler_approvePurchase_SessionApproveAny_TokenPresent_ConstraintsDenied +// is the MAJOR-finding regression test from the #60 independent review: the +// email deep-link flow forces a login, so a session is ALWAYS present +// alongside a non-empty token. Pre-review-fix, approvePurchase's dispatch +// treated authorizeSessionApprove's constraint denial as an ordinary +// isPermissionDenied 403 and fell through to approveViaToken, which never +// evaluates Constraints -- so a capped approve-any session who was ALSO the +// account's registered contact_email could approve straight past its own +// cap by using the emailed link instead of the dashboard Approve button. +// This session models exactly that: contact_email matches the session's +// own email, so authorizeApprovalAction (approveViaToken's own gate) would +// have granted it every bit as readily as the pre-fix dispatch's fall- +// through did. No GetGlobalConfig / GetCloudAccountFn / ApproveExecution +// mock is registered, so a regression that ever reaches approveViaToken +// again panics on an unexpected call instead of silently approving. +func TestHandler_approvePurchase_SessionApproveAny_TokenPresent_ConstraintsDenied(t *testing.T) { + ctx := context.Background() + execID := "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" + otherCreator := "creator-user-uuid" + accountID := "acct-1" + capped := &Session{UserID: "ffffffff-ffff-ffff-ffff-ffffffffffff", Email: "capped-contact@example.com"} + + mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: "valid-token", + Status: "pending", + CreatedByUserID: &otherCreator, + Recommendations: []config.RecommendationRecord{ + {Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000, CloudAccountID: &accountID}, + }, + } + mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) + // Deliberately NOT wired: GetCloudAccountFn (the account's contact_email + // lookup approveViaToken's authorizeApprovalAction gate depends on) is + // left unset. The fixed dispatch never reaches approveViaToken for this + // session at all, so this test doesn't need it; TestHandler_approveViaToken_ + // SessionApproveAny_ConstraintsDenied below exercises that gate directly + // (wired to succeed) to isolate approveConstraintsForSession's own check. + + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + mockAuth.On("ValidateSession", ctx, "capped-token").Return(capped, nil) + mockAuth.On("HasPermissionAPI", ctx, capped.UserID, "approve-any", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, capped.UserID, "approve-any", "purchases", + purchaseConstraintSets(exec.Recommendations)).Return(false, nil) + + mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) + + handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"authorization": "Bearer capped-token"}} + // Non-empty token: the email deep-link shape. + _, err := handler.approvePurchase(ctx, req, execID, "valid-token") + 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") + mockPurchase.AssertNotCalled(t, "ApproveAndExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockPurchase.AssertNotCalled(t, "ApproveExecution", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// TestHandler_authorizeSessionApprove_ApproveOwn_ConstraintsDenied mirrors +// the approve-any case above for approve-own:purchases (issue #60): the +// creator-ownership check passes, but the approve-own permission's own +// Constraints reject the request. +func TestHandler_authorizeSessionApprove_ApproveOwn_ConstraintsDenied(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + + userID := "cccccccc-cccc-cccc-cccc-cccccccccccc" + session := &Session{UserID: userID} + execution := &config.PurchaseExecution{ + ExecutionID: "exec-1", + CreatedByUserID: &userID, + Recommendations: []config.RecommendationRecord{ + {Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000}, + }, + } + + mockAuth.On("HasPermissionAPI", ctx, userID, "approve-any", "purchases").Return(false, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "approve-own", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, userID, "approve-own", "purchases", + purchaseConstraintSets(execution.Recommendations)).Return(false, nil) + + handler := &Handler{auth: mockAuth} + err := handler.authorizeSessionApprove(ctx, session, execution) + 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") +} + // --- F3 regression: global-config read error must fail closed (not execute) --- // TestHandler_approveViaToken_GlobalConfigError_FailsClosed pins the F3 fix: @@ -427,6 +609,65 @@ func TestHandler_approveViaToken_GlobalConfigError_FailsClosed(t *testing.T) { mock.Anything, mock.Anything, mock.Anything, mock.Anything) } +// TestHandler_approveViaToken_SessionApproveAny_ConstraintsDenied drives +// approveViaToken DIRECTLY (bypassing approvePurchase's dispatch) to prove +// approveConstraintsForSession's own, independent Constraints evaluation +// (issue #60 review follow-up finding #1's second half) denies a capped +// approve-any session even when authorizeApprovalAction's contact_email +// gate would otherwise let the request through. In production this +// function is only reached via the dispatch, which already blocks this +// same session earlier (see the TokenPresent test above); this test isolates +// approveConstraintsForSession's own wiring so a future refactor that +// changes how approveViaToken is reached still has direct coverage. +func TestHandler_approveViaToken_SessionApproveAny_ConstraintsDenied(t *testing.T) { + ctx := context.Background() + execID := "12121212-1212-1212-1212-121212121212" + accountID := "acct-capped" + capped := &Session{UserID: "13131313-1313-1313-1313-131313131313", Email: "capped-contact@example.com"} + + mockConfig := new(MockConfigStore) + t.Cleanup(func() { mockConfig.AssertExpectations(t) }) + exec := &config.PurchaseExecution{ + ExecutionID: execID, + ApprovalToken: "valid-token", + Status: "pending", + Recommendations: []config.RecommendationRecord{ + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000, CloudAccountID: &accountID}, + }, + } + // capped's own email is the account's registered contact_email, so + // authorizeApprovalAction's gate passes -- exactly the scenario where, + // pre-fix, the token flow's silence on Constraints mattered. + mockConfig.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, ContactEmail: capped.Email}, nil + } + // GetGlobalConfig is read twice: once (best-effort) inside + // authorizeApprovalAction, once inside requireDifferentApprover + // (mode off by default, so it returns nil without further reads). + mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + mockAuth.On("ValidateSession", ctx, "capped-token").Return(capped, nil) + mockAuth.On("HasPermissionAPI", ctx, capped.UserID, "approve-any", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, capped.UserID, "approve-any", "purchases", + purchaseConstraintSets(exec.Recommendations)).Return(false, nil) + + mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) + + handler := &Handler{purchase: mockPurchase, config: mockConfig, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"authorization": "Bearer capped-token"}} + _, err := handler.approveViaToken(ctx, req, exec, "valid-token") + 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") + mockPurchase.AssertNotCalled(t, "ApproveExecution", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockPurchase.AssertNotCalled(t, "ApproveAndExecute", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + // TestHandler_approvePurchaseViaSession_GlobalConfigError_FailsClosed pins the // F3 fix for the session (dashboard) approve path: same contract as the token // path — a transient config error must not discard the free-cancel window. @@ -437,10 +678,15 @@ func TestHandler_approvePurchaseViaSession_GlobalConfigError_FailsClosed(t *test mockConfig := new(MockConfigStore) exec := &config.PurchaseExecution{ - ExecutionID: execID, - ApprovalToken: "valid-token", - Status: "pending", - Recommendations: []config.RecommendationRecord{{ID: "r1"}}, + ExecutionID: execID, + ApprovalToken: "valid-token", + Status: "pending", + // Non-zero UpfrontCost: authorizeSessionApprove's requireNonZeroCommitment + // guard must not shadow the GetGlobalConfig failure this test targets + // (issue #60 review follow-up). + Recommendations: []config.RecommendationRecord{ + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, + }, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) // GetGlobalConfig always fails — approvePurchaseViaSession must return the @@ -565,7 +811,9 @@ func TestHandler_approvePurchase_AWSOrphanFallsThrough(t *testing.T) { ApprovalToken: "valid-token", Status: "pending", // CloudAccountID nil — but provider is AWS, so fallback applies. - Recommendations: []config.RecommendationRecord{{ID: "r1", Provider: "aws"}}, + // UpfrontCost non-zero so enforcePurchaseConstraints' zero-commitment + // guard doesn't preempt the orphan-fallback behavior this test targets. + Recommendations: []config.RecommendationRecord{{ID: "r1", Provider: "aws", UpfrontCost: 100}}, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) @@ -605,7 +853,9 @@ func TestHandler_approvePurchase_NonOrphanUnchanged(t *testing.T) { ApprovalToken: "valid-token", Status: "pending", // CloudAccountID is set — normal case, guard must not fire. - Recommendations: []config.RecommendationRecord{{ID: "r1", Provider: "azure", CloudAccountID: &accountID}}, + // UpfrontCost non-zero so enforcePurchaseConstraints' zero-commitment + // guard doesn't preempt the orphan-fallback behavior this test targets. + Recommendations: []config.RecommendationRecord{{ID: "r1", Provider: "azure", CloudAccountID: &accountID, UpfrontCost: 100}}, CloudAccountID: &accountID, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) @@ -1311,6 +1561,13 @@ func TestHandler_runPlannedPurchase(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockAuth.grantAdminPurchaser() + // runPlannedPurchase re-hydrates the execution to enforce the session's + // execute:purchases Constraints against its recommendations (issue #60). + mockStore.On("GetExecutionByID", ctx, "11111111-1111-1111-1111-111111111111").Return(&config.PurchaseExecution{ + ExecutionID: "11111111-1111-1111-1111-111111111111", + Status: "pending", + Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}}, + }, nil) // runPlannedPurchase ("Run now") must delegate to the same 4-eyes-gated, // CAS-guarded funnel ApproveAndExecute uses (issue #218) rather than a // bare TransitionExecutionStatus flip to "running" that no executor @@ -1333,6 +1590,79 @@ func TestHandler_runPlannedPurchase(t *testing.T) { mockPurchase.AssertExpectations(t) } +// runPlannedPurchaseGuardHarness wires a runPlannedPurchase call for a +// session holding admin:* (so update-any lets authorizeExecutionManagement +// pass) plus the given execute:purchases permission, against an execution +// carrying recs. RunPlannedPurchaseNow is deliberately left unstubbed: a +// regression that reaches the funnel panics on the unexpected call, and +// AssertNotCalled states the contract explicitly. +func runPlannedPurchaseGuardHarness(t *testing.T, executePerm auth.Permission, recs []config.RecommendationRecord) error { + t.Helper() + ctx := context.Background() + execID := "11111111-1111-1111-1111-111111111111" + + mockStore := new(MockConfigStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + mockStore.On("GetExecutionByID", ctx, execID).Return(&config.PurchaseExecution{ + ExecutionID: execID, + Status: "pending", + Recommendations: recs, + }, nil) + + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + session := &Session{UserID: "dddddddd-dddd-dddd-dddd-dddddddddddd", Email: "capped@example.com"} + mockAuth.On("ValidateSession", ctx, "capped-token").Return(session, nil) + mockAuth.grantPermissions([]auth.Permission{ + {Action: auth.ActionAdmin, Resource: auth.ResourceAll}, + executePerm, + }) + + mockPurchase := new(MockPurchaseManager) + t.Cleanup(func() { mockPurchase.AssertExpectations(t) }) + + handler := &Handler{purchase: mockPurchase, config: mockStore, auth: mockAuth} + req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer capped-token"}} + _, err := handler.runPlannedPurchase(ctx, req, execID) + mockPurchase.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + return err +} + +// TestHandler_runPlannedPurchase_ConstraintDenied: a MaxPurchaseAmount cap on +// the session's execute:purchases permission below the execution's total +// commitment must refuse "Run now" with 403 before the purchase funnel fires +// (issue #60). +func TestHandler_runPlannedPurchase_ConstraintDenied(t *testing.T) { + err := runPlannedPurchaseGuardHarness(t, + auth.Permission{ + Action: auth.ActionExecute, + Resource: auth.ResourcePurchases, + Constraints: &auth.PermissionConstraints{MaxPurchaseAmount: 100}, + }, + []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000}}, + ) + 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") +} + +// TestHandler_runPlannedPurchase_ZeroCommitment: an execution whose recs sum +// to $0 must be refused with 400 by requireNonZeroCommitment rather than +// reading as an uncapped MaxPurchaseAmount (issue #60 review follow-up). +func TestHandler_runPlannedPurchase_ZeroCommitment(t *testing.T) { + err := runPlannedPurchaseGuardHarness(t, + auth.Permission{Action: auth.ActionExecute, Resource: auth.ResourcePurchases}, + []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1"}}, + ) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "expected a clientError, got: %v", err) + assert.Equal(t, 400, ce.code) + assert.Contains(t, ce.Error(), "zero total commitment") +} + func TestHandler_deletePlannedPurchase(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -4325,7 +4655,9 @@ func TestHandler_authorizeSessionExecuteDirect_ExecuteOwn_NonOwner(t *testing.T) mockAuth.On("HasPermissionAPI", ctx, sessionUserID, "execute-own", "purchases").Return(true, nil) handler := &Handler{auth: mockAuth} - err := handler.authorizeSessionExecuteDirect(ctx, session, differentCreatorID) + // The ownership mismatch is rejected before any constraint set is built, + // so recs is nil here (no HasPermissionForConstraintsAPI mock needed). + err := handler.authorizeSessionExecuteDirect(ctx, session, differentCreatorID, nil) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "expected a clientError") @@ -4336,7 +4668,8 @@ func TestHandler_authorizeSessionExecuteDirect_ExecuteOwn_NonOwner(t *testing.T) // TestHandler_authorizeSessionExecuteDirect_AdminGroupViaExecuteAny verifies // that an Administrators-group user whose {admin,*} wildcard resolves to // execute-any is PERMITTED by the HasPermissionAPI path (not a dead -// session.Role shortcut that was removed in issue #940). +// session.Role shortcut that was removed in issue #940), and that the +// execute-any permission's own Constraints are then consulted (issue #60). func TestHandler_authorizeSessionExecuteDirect_AdminGroupViaExecuteAny(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) @@ -4345,15 +4678,78 @@ func TestHandler_authorizeSessionExecuteDirect_AdminGroupViaExecuteAny(t *testin adminUserID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" creatorID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" session := &Session{UserID: adminUserID} + recs := []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 500}} // Administrators-group wildcard {admin,*} covers execute-any. mockAuth.On("HasPermissionAPI", ctx, adminUserID, "execute-any", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, adminUserID, "execute-any", "purchases", + purchaseConstraintSets(recs)).Return(true, nil) handler := &Handler{auth: mockAuth} - err := handler.authorizeSessionExecuteDirect(ctx, session, creatorID) + err := handler.authorizeSessionExecuteDirect(ctx, session, creatorID, recs) require.NoError(t, err) } +// TestHandler_authorizeSessionExecuteDirect_ExecuteAny_ConstraintsDenied is +// the SEC-01 (#1141) follow-up regression test for issue #60: a session +// whose execute-any:purchases verb is granted (the bare-verb gate passes) +// but whose per-permission Constraints reject the request must receive a +// 403. Pre-fix, authorizeSessionExecuteDirect never consulted the +// execute-any permission's own Constraints, so this request was allowed +// through to directExecutePurchase. +func TestHandler_authorizeSessionExecuteDirect_ExecuteAny_ConstraintsDenied(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + + userID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + creatorID := "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + session := &Session{UserID: userID} + recs := []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000}} + + mockAuth.On("HasPermissionAPI", ctx, userID, "execute-any", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, userID, "execute-any", "purchases", + purchaseConstraintSets(recs)).Return(false, nil) + + handler := &Handler{auth: mockAuth} + err := handler.authorizeSessionExecuteDirect(ctx, session, creatorID, recs) + 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") +} + +// TestHandler_authorizeSessionExecuteDirect_ExecuteOwn_ConstraintsDenied +// mirrors the ExecuteAny case above for execute-own:purchases (issue #60): +// the creator-ownership check passes, but the execute-own permission's own +// Constraints reject the request. +func TestHandler_authorizeSessionExecuteDirect_ExecuteOwn_ConstraintsDenied(t *testing.T) { + ctx := context.Background() + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockAuth.AssertExpectations(t) }) + + userID := "cccccccc-cccc-cccc-cccc-cccccccccccc" + session := &Session{UserID: userID} + recs := []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 50000}} + + mockAuth.On("HasPermissionAPI", ctx, userID, "execute-any", "purchases").Return(false, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "execute-own", "purchases").Return(true, nil) + mockAuth.On("HasPermissionForConstraintsAPI", ctx, userID, "execute-own", "purchases", + purchaseConstraintSets(recs)).Return(false, nil) + + handler := &Handler{auth: mockAuth} + // creatorID == userID: the ownership gate passes, so the constraint + // check is the only thing standing between this session and a direct + // execute past its cap. + err := handler.authorizeSessionExecuteDirect(ctx, session, userID, recs) + 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") +} + // TestHandler_authorizeSessionExecuteDirect_NoGrant verifies that a session // without execute-any or execute-own on purchases is rejected with 403. func TestHandler_authorizeSessionExecuteDirect_NoGrant(t *testing.T) { @@ -4369,7 +4765,7 @@ func TestHandler_authorizeSessionExecuteDirect_NoGrant(t *testing.T) { mockAuth.On("HasPermissionAPI", ctx, userID, "execute-own", "purchases").Return(false, nil) handler := &Handler{auth: mockAuth} - err := handler.authorizeSessionExecuteDirect(ctx, session, creatorID) + err := handler.authorizeSessionExecuteDirect(ctx, session, creatorID, nil) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "expected a clientError") @@ -4383,7 +4779,7 @@ func TestHandler_authorizeSessionExecuteDirect_NilAuth(t *testing.T) { session := &Session{UserID: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"} handler := &Handler{auth: nil} - err := handler.authorizeSessionExecuteDirect(ctx, session, "") + err := handler.authorizeSessionExecuteDirect(ctx, session, "", nil) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok, "expected a clientError") @@ -5782,7 +6178,15 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DeniesSelfApprove(t *testi ApprovalToken: "valid-token", Status: "pending", CreatedByUserID: &adminID, - Recommendations: []config.RecommendationRecord{{ID: "r1"}}, + // Non-zero UpfrontCost: this session reaches authorizeSessionApprove's + // approve-own branch (the bare admin wildcard covers approve-own, + // which is not carved out the way approve-any is), which now routes + // through enforcePurchaseConstraints and would otherwise refuse the + // $0-commitment batch before ever reaching the 4-eyes check this test + // targets (issue #60 review follow-up). + Recommendations: []config.RecommendationRecord{ + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, + }, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(fourEyesCfgOn(), nil) @@ -5831,6 +6235,13 @@ func TestHandler_approvePurchaseViaSession_FourEyesOn_DifferentApproverSucceeds( ApprovalToken: "valid-token", Status: "pending", CreatedByUserID: &creatorID, + // authorizeSessionApprove builds constraint sets from Recommendations + // (issue #60); an empty set is a caller bug the constraint check + // fails closed on, so this fixture needs at least one recommendation + // like every other execution a real submit would have produced. + Recommendations: []config.RecommendationRecord{ + {Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, + }, } approved := &config.PurchaseExecution{ExecutionID: execID, PlanID: planID, Status: "approved", StepNumber: 1} mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) diff --git a/internal/api/handler_ri_exchange.go b/internal/api/handler_ri_exchange.go index 07a60e34a..ebfe84c81 100644 --- a/internal/api/handler_ri_exchange.go +++ b/internal/api/handler_ri_exchange.go @@ -1078,7 +1078,7 @@ func (h *Handler) checkAzureExecuteConstraints(ctx context.Context, session *Ses attempt.MaxPurchaseAmount = math.MaxFloat64 } - err := h.requirePermissionConstraints(ctx, session, "ri-exchange", []auth.PermissionConstraints{attempt}) + err := h.requirePermissionConstraints(ctx, session, "execute", "ri-exchange", []auth.PermissionConstraints{attempt}) if err == nil || isUSD { return err } @@ -1087,7 +1087,7 @@ func (h *Handler) checkAzureExecuteConstraints(ctx context.Context, session *Ses // specifically the cause. withoutAmount := base withoutAmount.MaxPurchaseAmount = 0 - if otherErr := h.requirePermissionConstraints(ctx, session, "ri-exchange", []auth.PermissionConstraints{withoutAmount}); otherErr != nil { + if otherErr := h.requirePermissionConstraints(ctx, session, "execute", "ri-exchange", []auth.PermissionConstraints{withoutAmount}); otherErr != nil { return otherErr } return NewClientError(403, fmt.Sprintf( @@ -1846,7 +1846,7 @@ func (h *Handler) executeExchange(ctx context.Context, req *events.LambdaFunctio if err != nil { return nil, fmt.Errorf("failed to resolve cloud account scope: %w", err) } - err = h.requirePermissionConstraints(ctx, session, "ri-exchange", []auth.PermissionConstraints{{ + err = h.requirePermissionConstraints(ctx, session, "execute", "ri-exchange", []auth.PermissionConstraints{{ AccountIDs: []string{cloudAccountID}, Providers: []string{string(common.ProviderAWS)}, Services: []string{string(common.ServiceEC2)}, diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 27e14b1e2..340304de5 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -1254,6 +1254,13 @@ func TestHandler_HandleRequest_RunPlannedPurchase(t *testing.T) { mockAuth.On("ValidateCSRFToken", ctx, mock.Anything, mock.Anything).Return(nil) mockPurchase := new(MockPurchaseManager) + // runPlannedPurchase re-hydrates the execution to enforce the session's + // execute:purchases Constraints against its recommendations (issue #60). + mockStore.On("GetExecutionByID", mock.Anything, "11111111-1111-1111-1111-111111111111").Return(&config.PurchaseExecution{ + ExecutionID: "11111111-1111-1111-1111-111111111111", + Status: "pending", + Recommendations: []config.RecommendationRecord{{Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}}, + }, nil) // This request authenticates via the admin API key (X-API-Key), which // resolves to the stateless admin-api-key principal rather than the // bearer-token session, so the actor identity fourEyesActorIdentity diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go index 7fb4a1742..8a319ccf4 100644 --- a/internal/api/middleware_test.go +++ b/internal/api/middleware_test.go @@ -349,10 +349,17 @@ func TestApproveViaSession_PassesCSRF(t *testing.T) { mockConfig := new(MockConfigStore) exec := &config.PurchaseExecution{ - ExecutionID: execID, - ApprovalToken: "email-tok", - Status: "pending", - Recommendations: []config.RecommendationRecord{{ID: "r1"}}, + ExecutionID: execID, + ApprovalToken: "email-tok", + Status: "pending", + // Non-zero UpfrontCost: this test's CSRF token is valid, so the flow + // reaches authorizeSessionApprove's approve-any branch, which now + // routes through enforcePurchaseConstraints and would otherwise + // refuse the $0-commitment batch before the CSRF-pass assertion this + // test targets is reached (issue #60 review follow-up). + Recommendations: []config.RecommendationRecord{ + {ID: "r1", Provider: "aws", Service: "ec2", Region: "us-east-1", UpfrontCost: 100}, + }, } mockConfig.On("GetExecutionByID", ctx, execID).Return(exec, nil) mockConfig.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) diff --git a/internal/api/purchase_pricing.go b/internal/api/purchase_pricing.go index 3308258e3..855392bf1 100644 --- a/internal/api/purchase_pricing.go +++ b/internal/api/purchase_pricing.go @@ -22,7 +22,7 @@ func (h *Handler) priceAndEnforcePurchaseConstraints(ctx context.Context, sessio if err := h.priceRecommendationsFromStore(ctx, recs); err != nil { return err } - return h.enforcePurchaseConstraints(ctx, session, recs) + return h.enforcePurchaseConstraints(ctx, session, "execute", recs) } // priceRecommendationsFromStore rewrites recs in place. Each rec must match