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).

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