Skip to content

sec(purchases): standard user can pause/resume other users' scheduled purchases (no ownership gate) #950

Description

@cristim

Summary

On the Scheduled / Planned Purchases page, a standard (non-admin) user can act on scheduled purchases created by other users. Action buttons render on other users' rows and, for pause/resume, the backend actually honors the request. Planned purchases have no per-record ownership gate anywhere in the stack (neither FE nor backend), unlike Purchase History (cancel-own / approve-own / retry-own, issues #46 / #286 / #47) which correctly enforces created_by_user_id == session.user_id.

Found in QA as a Standard User. Cross-cutting with the group-only authz model (#907 / #912) and the Purchaser-group / -any carve-outs (#923 / #924) that just reached prod.

Action / Expected / Actual

  • Action: As a standard user, open Scheduled Purchases and look at rows created by a different user (same accessible account). Click the action buttons.
  • Expected: Scheduled purchases created by other users have no actionable buttons for a standard user (a standard user must not manage others' scheduled purchases). Backend must fail closed for a non-owner who lacks the relevant -any permission.
  • Actual: Action buttons render on other users' rows and are clickable; pause/resume actually succeed on the backend.

Root cause (FE vs backend distinction)

This is a true backend authz hole for pause/resume, plus a missing ownership model FE-wide.

Backend (the serious part)

internal/api/handler_purchases.go:

  • pausePlannedPurchase (~L194) and resumePlannedPurchase (~L215) gate only on requirePermission("update", "purchases") + requireExecutionAccess (account-scope only). No created_by_user_id == session.UserID ownership check.
  • runPlannedPurchase (~L236) requires execute:purchases; deletePlannedPurchase (~L262) requires delete:purchases.

internal/auth/types.go DefaultUserPermissions() grants a standard RoleUser update:purchases (L478) but not execute:purchases or delete:purchases.

Net result for a standard user with access to the same account:

  • pause / resume: backend ALLOWS it on another user's execution (holds update:purchases, no ownership check). True authz hole.
  • run / delete: backend 403s (standard user lacks the verb) regardless of ownership — verb-gated, so safe for the default user role. But note: any custom group granting execute/delete:purchases without an -any analogue would also let a non-owner act on anyone's row, since there is no ownership check on these handlers either.

internal/api/scoping.go requireExecutionAccess / requirePlanAccess verify only account scope (allowed_accounts), never per-record ownership. They are the only per-record gate the planned-purchase handlers apply.

Frontend (button visibility)

frontend/src/plans.ts renderPlannedPurchaseRow (~L658-704) gates buttons purely by role permission (canAccess('update','plans'), canAccess('delete','plans')), never by ownership. The PlannedPurchase payload built by buildPlannedPurchase (internal/api/handler_purchases.go ~L164) does not include created_by_user_id, so the FE has no field to gate ownership on even if it wanted to.

Severity rationale

  • The money-spending verbs (run = execute:purchases, delete = delete:purchases) are already verb-gated away from the default user role, so the most damaging actions are not reachable by a plain standard user.
  • The reachable hole is pause/resume on another user's scheduled purchase, which can disrupt/delay a colleague's planned commitment without authorization. This is an integrity/separation-of-duties violation, not a fund-loss path, hence P1 / severity-high rather than P0 / critical.
  • Affects any multi-user org sharing account scope (impact/many).

Suggested fix (mirror the History ownership model)

  1. Backend: add an ownership gate to pause/resume/run/delete planned-purchase handlers: allow when execution.created_by_user_id == session.UserID, OR the session holds an -any permission (admin via wildcard, or a future operator group). Fail closed for non-owners without -any. Reuse the existing pattern from evaluateCancel* / retry-own / approve-own in handler_purchases.go. NULL-creator legacy rows stay out of reach for non--any users.
  2. API payload: surface created_by_user_id on PlannedPurchase (mirror PurchaseHistoryRecord.CreatedByUserID).
  3. Frontend: gate the row action buttons on ownership (purchase.created_by_user_id === currentUser.id) in addition to the verb check, so non-owners see no actionable buttons.
  4. Regression test: assert a non-owner standard user (holds update:purchases, no -any) is denied (403/404) on pause/resume of another user's execution, and that an owner is allowed.

Verified against

origin/feat/multicloud-web-frontend @ f035285b6 (post #924/#941 deploy).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions