From 9a5c4dc7daadb78dd54957fa776e9e922ad9f476 Mon Sep 17 00:00:00 2001 From: Astrid Gealer Date: Tue, 20 Oct 2026 08:46:12 +0100 Subject: [PATCH] sec(api): bound update-any by its Constraints on planned-purchase run The run (execute now) route authorized through authorizeExecutionManagement, which lets any update-any holder act on another user's scheduled purchase without evaluating update-any's Constraints. Route it through the constraint-aware helper merged for pause/resume (#1098), renamed to authorizeBoundedExecutionManagement, and re-run the same decision on the row the claim CAS returns so an edit landing before the claim cannot carry the row out of scope. Closes #1129 Co-Authored-By: Claude Opus 5.5 --- internal/api/claimed_row_check.go | 8 +- internal/api/handler_purchases.go | 65 +++-- ...rchases_run_update_any_integration_test.go | 115 +++++++++ .../handler_purchases_run_update_any_test.go | 227 ++++++++++++++++++ 4 files changed, 396 insertions(+), 19 deletions(-) create mode 100644 internal/api/handler_purchases_run_update_any_integration_test.go create mode 100644 internal/api/handler_purchases_run_update_any_test.go diff --git a/internal/api/claimed_row_check.go b/internal/api/claimed_row_check.go index 25cea8e28..d8745f58e 100644 --- a/internal/api/claimed_row_check.go +++ b/internal/api/claimed_row_check.go @@ -21,10 +21,14 @@ import ( // without touching the row. A post-claim refusal ends the row "failed" with // no_submission set (see purchase.ClaimedRowCheck). -// runNowClaimCheck is runPlannedPurchase's execute:purchases Constraints -// check, against the claimed row. +// runNowClaimCheck is runPlannedPurchase's update-any / creator check +// (issue #1129) and execute:purchases Constraints check, against the claimed +// row. func (h *Handler) runNowClaimCheck(session *Session) purchase.ClaimedRowCheck { return func(ctx context.Context, claimed *config.PurchaseExecution) error { + if err := h.authorizeClaimedRowManagement(ctx, session, claimed); err != nil { + return err + } return h.enforcePurchaseConstraints(ctx, session, "execute", claimed.Recommendations) } } diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index d90c7282b..7b90daf01 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -152,7 +152,9 @@ func buildPlannedPurchase(plan *config.PurchasePlan, exec *config.PurchaseExecut // closing the authz hole in issue #950 where any holder of update:purchases // could act on another user's scheduled purchase. It runs AFTER the // per-handler verb check (update / execute / delete) and the account-scope -// check (requireExecutionAccess); those still apply unchanged. +// check (requireExecutionAccess); those still apply unchanged. Pause, resume +// and run reach it through authorizeBoundedExecutionManagement, which bounds +// update-any by its Constraints first (issues #1098, #1129). // // Gate logic (mirrors authorizeSessionCancel / authorizeSessionApprove): // - stateless admin API key: always permitted (apiKeyAdminUserID sentinel). @@ -205,17 +207,12 @@ func (h *Handler) authorizeExecutionManagement(ctx context.Context, session *Ses return nil } -// authorizeExecutionPauseResume is authorizeExecutionManagement for pause and -// resume, with update-any bounded by its Constraints (issue #1098). A session -// holding update-any is checked against the strict per-recommendation scope of -// the STORED row (revokeExecutionConstraintSets), mirroring the planned-purchase -// cancel gates. An in-scope grant is permitted without an owner check. A -// Constraints denial falls back to the creator match and otherwise stays the -// refusal; any other error (a probe or constraint-store failure) is returned -// and never read as a denial, so it cannot fall through to a weaker branch. -// Sessions without update-any take the unchanged authorizeExecutionManagement -// path. -func (h *Handler) authorizeExecutionPauseResume(ctx context.Context, session *Session, executionID string) error { +// authorizeBoundedExecutionManagement is authorizeExecutionManagement with +// update-any bounded by its Constraints, used by pause, resume (issue #1098) +// and run (issue #1129). Sessions without update-any take the unchanged +// authorizeExecutionManagement path; a session holding update-any is checked +// against the stored row by authorizeUpdateAnyOnRow. +func (h *Handler) authorizeBoundedExecutionManagement(ctx context.Context, session *Session, executionID string) error { if session.UserID == apiKeyAdminUserID { return nil } @@ -233,6 +230,17 @@ func (h *Handler) authorizeExecutionPauseResume(ctx context.Context, session *Se if err != nil { return err } + return h.authorizeUpdateAnyOnRow(ctx, session, execution) +} + +// authorizeUpdateAnyOnRow checks a session holding update-any against the +// strict per-recommendation scope of the STORED row +// (revokeExecutionConstraintSets), mirroring the planned-purchase cancel +// gates. An in-scope grant is permitted without an owner check. A Constraints +// denial falls back to the creator match and otherwise stays the refusal; any +// other error (a probe or constraint-store failure) is returned and never read +// as a denial, so it cannot fall through to a weaker branch. +func (h *Handler) authorizeUpdateAnyOnRow(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { anyErr := h.requirePermissionConstraints(ctx, session, auth.ActionUpdateAny, auth.ResourcePurchases, revokeExecutionConstraintSets(execution)) if anyErr == nil || !isForbidden(anyErr) { return anyErr @@ -243,6 +251,27 @@ func (h *Handler) authorizeExecutionPauseResume(ctx context.Context, session *Se return anyErr } +// authorizeClaimedRowManagement re-runs authorizeBoundedExecutionManagement's +// decision on a row already in hand: the row run's claim CAS returned +// (issue #415), so an edit landing between the pre-claim read and the claim +// cannot carry the row out of a bounded update-any's scope. +func (h *Handler) authorizeClaimedRowManagement(ctx context.Context, session *Session, execution *config.PurchaseExecution) error { + if session.UserID == apiKeyAdminUserID { + return nil + } + hasAny, err := h.sessionHoldsVerb(ctx, session, auth.ActionUpdateAny) + if err != nil { + return err + } + if hasAny { + return h.authorizeUpdateAnyOnRow(ctx, session, execution) + } + if !isExecutionCreator(session, execution) { + return NewClientError(403, "permission denied: cannot manage another user's scheduled purchase") + } + return nil +} + // isExecutionCreator requires both IDs to be non-empty and equal, so an // empty-string collision (legacy NULL creator, missing session UserID) never // matches. @@ -262,7 +291,7 @@ func (h *Handler) pausePlannedPurchase(ctx context.Context, req *events.LambdaFu if err := h.requireExecutionAccess(ctx, session, executionID); err != nil { return nil, err } - if err := h.authorizeExecutionPauseResume(ctx, session, executionID); err != nil { + if err := h.authorizeBoundedExecutionManagement(ctx, session, executionID); err != nil { return nil, err } @@ -286,7 +315,7 @@ func (h *Handler) resumePlannedPurchase(ctx context.Context, req *events.LambdaF if err := h.requireExecutionAccess(ctx, session, executionID); err != nil { return nil, err } - if err := h.authorizeExecutionPauseResume(ctx, session, executionID); err != nil { + if err := h.authorizeBoundedExecutionManagement(ctx, session, executionID); err != nil { return nil, err } @@ -383,7 +412,9 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc if err := h.requireExecutionAccess(ctx, session, executionID); err != nil { return nil, err } - if err := h.authorizeExecutionManagement(ctx, session, executionID); err != nil { + // update-any lets a non-creator run the row only within its Constraints + // (issue #1129). + if err := h.authorizeBoundedExecutionManagement(ctx, session, executionID); err != nil { return nil, err } @@ -391,8 +422,8 @@ func (h *Handler) runPlannedPurchase(ctx context.Context, req *events.LambdaFunc // 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/ + // update-any:purchases (which lets a non-creator past the creator check + // within its own Constraints) 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 diff --git a/internal/api/handler_purchases_run_update_any_integration_test.go b/internal/api/handler_purchases_run_update_any_integration_test.go new file mode 100644 index 000000000..ecd4c1830 --- /dev/null +++ b/internal/api/handler_purchases_run_update_any_integration_test.go @@ -0,0 +1,115 @@ +//go:build integration + +package api + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// Issue #1129 against real PostgreSQL: a non-creator runs the row through an +// update-any bounded to provider aws. A second writer moves the row to gcp +// after the handler's last pre-claim check and before the claim CAS; the +// funnel's post-claim re-check must refuse the row the CAS returned. Real +// store, handler and purchase manager, counting fake provider (runNowService); +// the Constraints are decided by the real auth matcher. Nothing here reaches a +// cloud API. + +// insertNonCreator adds a real users row for the operator: the claim stamps +// the session user onto transitioned_by, a foreign key. +func insertNonCreator(t *testing.T, f *pauseClaimFixture) string { + t.Helper() + id := uuid.NewString() + _, err := f.pool.Exec(f.ctx, `INSERT INTO users (id,email,password_hash,salt,active,group_ids,created_at,updated_at) SELECT $1,'operator@example.invalid','x','y',true,ARRAY[g.id],now(),now() FROM groups g ORDER BY g.name LIMIT 1`, id) + require.NoError(t, err) + require.NotEqual(t, id, *f.execution.CreatedByUserID) + return id +} + +// moveProviderDuringExecuteCheck answers permission checks for a non-creator +// session with the real matcher and, on the first execute Constraints check +// (the handler's last pre-claim check), rewrites the row's provider with a +// separate UPDATE. move=false leaves the row alone (the unmodified control). +func moveProviderDuringExecuteCheck(t *testing.T, f *pauseClaimFixture, perms []auth.Permission, move bool) *MockAuthService { + t.Helper() + operator := insertNonCreator(t, f) + var once sync.Once + mockAuth := new(MockAuthService) + mockAuth.On("ValidateSession", mock.Anything, "admin-token").Return(&Session{UserID: operator, Email: "operator@example.invalid"}, nil).Maybe() + mockAuth.On("ValidateCSRFToken", mock.Anything, "admin-token", "").Return(nil).Maybe() + authCtx := &auth.AuthContext{Permissions: perms} + mockAuth.On("HasPermissionAPI", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(func(action, resource string) bool { return authCtx.HasPermission(action, resource) }, nil).Maybe() + mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return(nil, nil).Maybe() + mockAuth.On("HasPermissionForConstraintsAPI", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(constraintDecisionFunc(func(action, resource string, sets []auth.PermissionConstraints) (bool, error) { + allowed, err := auth.PermissionsAllowForConstraintSets(perms, action, resource, sets) + if move && action == auth.ActionExecute { + once.Do(func() { moveRowProvider(t, f, "gcp") }) + } + return allowed, err + }), nil).Maybe() + return mockAuth +} + +func moveRowProvider(t *testing.T, f *pauseClaimFixture, provider string) { + t.Helper() + recs := fanoutRecs() + for i := range recs { + recs[i].CloudAccountID = f.execution.CloudAccountID + recs[i].Provider = provider + } + payload, err := json.Marshal(recs) + require.NoError(t, err) + tag, err := f.pool.Exec(f.ctx, `UPDATE purchase_executions SET recommendations = $2 WHERE execution_id = $1`, f.execution.ExecutionID, payload) + require.NoError(t, err) + require.EqualValues(t, 1, tag.RowsAffected()) +} + +func runBoundPerms() []auth.Permission { + return []auth.Permission{ + {Action: auth.ActionExecute, Resource: auth.ResourcePurchases}, + {Action: auth.ActionUpdateAny, Resource: auth.ResourcePurchases, Constraints: &auth.PermissionConstraints{Providers: []string{"aws"}}}, + } +} + +func TestRunNow_Postgres_RowMovedOutOfUpdateAnyScopeBeforeClaimIsRefused(t *testing.T) { + f, service := newRunNowFixture(t, 0, fanoutRecs()) + f.handler.auth = moveProviderDuringExecuteCheck(t, f, runBoundPerms(), true) + + _, err := f.runNow() + + ce, ok := IsClientError(err) + require.True(t, ok, "expected a client-facing error, got %v", err) + assert.Equal(t, 403, ce.code) + assert.Contains(t, ce.message, "nothing was bought") + assert.Zero(t, service.calls.Load(), "the moved row must never reach the provider") + row, getErr := f.store.GetExecutionByID(f.ctx, f.execution.ExecutionID) + require.NoError(t, getErr) + assert.Equal(t, "gcp", row.Recommendations[0].Provider, "the race must have landed") + assert.Equal(t, "failed", row.Status) + assert.True(t, row.NoSubmission, "refused before any provider call") + var histories int + require.NoError(t, f.pool.QueryRow(f.ctx, "SELECT count(*) FROM purchase_history WHERE plan_id=$1", f.planID).Scan(&histories)) + assert.Zero(t, histories) +} + +func TestRunNow_Postgres_InScopeUpdateAnyNonCreatorPurchases(t *testing.T) { + f, service := newRunNowFixture(t, 0, fanoutRecs()) + f.handler.auth = moveProviderDuringExecuteCheck(t, f, runBoundPerms(), false) + + _, err := f.runNow() + require.NoError(t, err) + + assert.EqualValues(t, 1, service.calls.Load()) + row, getErr := f.store.GetExecutionByID(f.ctx, f.execution.ExecutionID) + require.NoError(t, getErr) + assert.Equal(t, "completed", row.Status) +} diff --git a/internal/api/handler_purchases_run_update_any_test.go b/internal/api/handler_purchases_run_update_any_test.go new file mode 100644 index 000000000..0e3eb4f3f --- /dev/null +++ b/internal/api/handler_purchases_run_update_any_test.go @@ -0,0 +1,227 @@ +package api + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/auth" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// Issue #1129: run (execute now) let an update-any holder run another user's +// scheduled purchase without evaluating update-any's Constraints. The +// execute:purchases grant in these tests is unbounded, so only the update-any +// bound can refuse. The runRouteRec row is azure/compute/westeurope on account +// "acct-1". + +func runRouteRec() config.RecommendationRecord { + acct := "acct-1" + return config.RecommendationRecord{ID: "r1", Provider: "azure", Service: "compute", Region: "westeurope", CloudAccountID: &acct, Count: 1, UpfrontCost: 100} +} + +var unboundedExecute = auth.Permission{Action: auth.ActionExecute, Resource: auth.ResourcePurchases} + +// runPlanned runs the route through mp as revokeConstraintsUser against a +// pending row created by creator. mp.ClaimedRow, when set, is the row the +// claim CAS returns. +func runPlanned(t *testing.T, mp *MockPurchaseManager, creator *string, recs []config.RecommendationRecord, perms ...auth.Permission) error { + t.Helper() + exec := cancelRouteExec("pending") + exec.CreatedByUserID = creator + exec.Recommendations = []config.RecommendationRecord{runRouteRec()} + if recs != nil { + exec.Recommendations = recs + } + h, _ := cancelRouteHandler(exec, append(perms, unboundedExecute)...) + mp.On("RunPlannedPurchaseNow", mock.Anything, cancelExecID, mock.Anything, mock.Anything). + Return(&config.PurchaseExecution{ExecutionID: cancelExecID, Status: "completed"}, "", nil).Maybe() + h.purchase = mp + _, err := h.runPlannedPurchase(context.Background(), deletePlannedReq(), cancelExecID) + return err +} + +func TestRunPlanned_UpdateAnyConstraintDimensions(t *testing.T) { + t.Parallel() + other := pauseResumeOtherUser + denying := map[string]*auth.PermissionConstraints{ + "providers": {Providers: []string{"aws"}}, + "services": {Services: []string{"rds"}}, + "regions": {Regions: []string{"eastus"}}, + "account ids": {AccountIDs: []string{"other-acct"}}, + } + allowing := map[string]*auth.PermissionConstraints{ + "providers": {Providers: []string{"azure"}}, + "services": {Services: []string{"compute"}}, + "regions": {Regions: []string{"westeurope"}}, + "account ids": {AccountIDs: []string{"acct-1"}}, + "none": nil, + } + for dim, c := range denying { + t.Run("deny/"+dim, func(t *testing.T) { + t.Parallel() + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &other, nil, revokePerm(auth.ActionUpdateAny, c)) + requireRevokeForbidden(t, err, constraintDenied) + mp.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + } + for dim, c := range allowing { + t.Run("allow/"+dim, func(t *testing.T) { + t.Parallel() + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &other, nil, revokePerm(auth.ActionUpdateAny, c)) + require.NoError(t, err) + mp.AssertNumberOfCalls(t, "RunPlannedPurchaseNow", 1) + }) + } +} + +func TestRunPlanned_UpdateAnyStrictScope(t *testing.T) { + t.Parallel() + other := pauseResumeOtherUser + azureOnly := revokePerm(auth.ActionUpdateAny, &auth.PermissionConstraints{Providers: []string{"azure"}}) + t.Run("every recommendation must be in scope", func(t *testing.T) { + t.Parallel() + aws := recFor("acct-1", "aws", "ec2", "us-east-1") + aws.UpfrontCost = 100 + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &other, []config.RecommendationRecord{runRouteRec(), aws}, azureOnly) + requireRevokeForbidden(t, err, constraintDenied) + mp.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + t.Run("admin is unaffected", func(t *testing.T) { + t.Parallel() + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &other, nil, auth.Permission{Action: auth.ActionAdmin, Resource: auth.ResourceAll}) + require.NoError(t, err) + mp.AssertNumberOfCalls(t, "RunPlannedPurchaseNow", 1) + }) +} + +// A bounded update-any denial still lets the row's creator run their own +// scheduled purchase, and keeps the constraint message for anyone else. +func TestRunPlanned_UpdateAnyDenialFallsBackToCreatorOnly(t *testing.T) { + t.Parallel() + creator, other := revokeConstraintsUser, pauseResumeOtherUser + t.Run("creator allowed", func(t *testing.T) { + t.Parallel() + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &creator, nil, awsOnly(auth.ActionUpdateAny)) + require.NoError(t, err) + mp.AssertNumberOfCalls(t, "RunPlannedPurchaseNow", 1) + }) + t.Run("non-creator denied", func(t *testing.T) { + t.Parallel() + mp := &MockPurchaseManager{} + err := runPlanned(t, mp, &other, nil, awsOnly(auth.ActionUpdateAny)) + requireRevokeForbidden(t, err, constraintDenied) + mp.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) +} + +// The funnel re-runs the update-any bound on the row its claim returns: a row +// edited out of scope between the pre-claim read and the claim is not bought. +func TestRunPlanned_UpdateAnyBoundRecheckedOnClaimedRow(t *testing.T) { + t.Parallel() + other := pauseResumeOtherUser + azureOnly := revokePerm(auth.ActionUpdateAny, &auth.PermissionConstraints{Providers: []string{"azure"}}) + aws := recFor("acct-1", "aws", "ec2", "us-east-1") + aws.UpfrontCost = 100 + claimed := cancelRouteExec("running") + claimed.CreatedByUserID = &other + claimed.Recommendations = []config.RecommendationRecord{aws} + + mp := &MockPurchaseManager{ClaimedRow: claimed} + err := runPlanned(t, mp, &other, nil, azureOnly) + requireClaimedRowRefused(t, mp, err) + mp.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// An error evaluating update-any's Constraints is not a denial: it must not +// fall back to the creator branch (the session created the row, so that branch +// would allow) and must not run the purchase. +func TestRunPlanned_UpdateAnyConstraintErrorDoesNotFallBack(t *testing.T) { + t.Parallel() + boom := errors.New("constraint store down") + owner := revokeConstraintsUser + exec := cancelRouteExec("pending") + exec.CreatedByUserID = &owner + exec.Recommendations = []config.RecommendationRecord{runRouteRec()} + store := new(MockConfigStore) + store.On("GetExecutionByID", mock.Anything, cancelExecID).Return(exec, nil) + m := new(MockAuthService) + m.On("ValidateSession", mock.Anything, "sess-tok").Return(&Session{UserID: owner}, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, owner).Return([]string{}, nil) + m.On("HasPermissionAPI", mock.Anything, owner, mock.Anything, auth.ResourcePurchases).Return(true, nil) + m.On("HasPermissionForConstraintsAPI", mock.Anything, owner, auth.ActionUpdateAny, auth.ResourcePurchases, mock.Anything).Return(false, boom) + m.On("HasPermissionForConstraintsAPI", mock.Anything, owner, auth.ActionExecute, auth.ResourcePurchases, mock.Anything).Return(true, nil).Maybe() + mp := new(MockPurchaseManager) + mp.On("RunPlannedPurchaseNow", mock.Anything, cancelExecID, mock.Anything, mock.Anything). + Return(&config.PurchaseExecution{ExecutionID: cancelExecID, Status: "completed"}, "", nil).Maybe() + + h := &Handler{config: store, auth: m, purchase: mp} + resp, err := h.runPlannedPurchase(context.Background(), deletePlannedReq(), cancelExecID) + require.ErrorIs(t, err, boom) + assert.Nil(t, resp) + mp.AssertNotCalled(t, "RunPlannedPurchaseNow", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// update-any Constraints on run follow the API key, not its owner (whose +// group holds an unbounded update-any). The keyScopeExec row is aws/ec2/ +// us-east-1, created by another user. +func TestRunPlanned_APIKeyUpdateAnyFollowsKeyConstraints(t *testing.T) { + var run keyScopeRoute + for _, rt := range keyScopeRoutes { + if rt.name == "run" { + run = rt + } + } + require.Equal(t, "run", run.name) + keyPerms := func(provider string) []auth.Permission { + return []auth.Permission{ + unboundedExecute, + revokePerm(auth.ActionUpdateAny, &auth.PermissionConstraints{Providers: []string{provider}}), + } + } + t.Run("key bounded to another provider is refused", func(t *testing.T) { + code, reached := serveKeyScope(t, run, newKeyScopeAuth(keyPerms("azure")), keyScopeVictim, keyHeaders()) + assert.Equal(t, http.StatusForbidden, code) + assert.False(t, reached, "a refused request must not reach the purchase") + }) + t.Run("key bounded to the row's provider is allowed", func(t *testing.T) { + _, reached := serveKeyScope(t, run, newKeyScopeAuth(keyPerms("aws")), keyScopeVictim, keyHeaders()) + assert.True(t, reached) + }) +} + +// authorizeClaimedRowManagement's branches outside update-any: the admin key +// passes, a session without update-any must be the claimed row's creator, and +// a failed verb probe is returned rather than read as either answer. +func TestAuthorizeClaimedRowManagement_WithoutUpdateAny(t *testing.T) { + t.Parallel() + creator, other := revokeConstraintsUser, pauseResumeOtherUser + row := func(by *string) *config.PurchaseExecution { + exec := cancelRouteExec("running") + exec.CreatedByUserID = by + return exec + } + h, _ := cancelRouteHandler(row(&creator), unboundedExecute) + ctx := context.Background() + + require.NoError(t, h.authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(&creator))) + require.NoError(t, h.authorizeClaimedRowManagement(ctx, &Session{UserID: apiKeyAdminUserID}, row(&other))) + requireRevokeForbidden(t, h.authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(&other)), "another user's scheduled purchase") + requireRevokeForbidden(t, h.authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(nil)), "another user's scheduled purchase") + + boom := errors.New("auth store down") + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, creator, auth.ActionUpdateAny, auth.ResourcePurchases).Return(false, boom) + err := (&Handler{auth: m}).authorizeClaimedRowManagement(ctx, &Session{UserID: creator}, row(&creator)) + require.ErrorIs(t, err, boom) +}