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)
- 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.
- API payload: surface
created_by_user_id on PlannedPurchase (mirror PurchaseHistoryRecord.CreatedByUserID).
- 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.
- 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).
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 /
-anycarve-outs (#923 / #924) that just reached prod.Action / Expected / Actual
-anypermission.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) andresumePlannedPurchase(~L215) gate only onrequirePermission("update", "purchases")+requireExecutionAccess(account-scope only). Nocreated_by_user_id == session.UserIDownership check.runPlannedPurchase(~L236) requiresexecute:purchases;deletePlannedPurchase(~L262) requiresdelete:purchases.internal/auth/types.goDefaultUserPermissions()grants a standardRoleUserupdate:purchases(L478) but notexecute:purchasesordelete:purchases.Net result for a standard user with access to the same account:
update:purchases, no ownership check). True authz hole.execute/delete:purchaseswithout an-anyanalogue would also let a non-owner act on anyone's row, since there is no ownership check on these handlers either.internal/api/scoping.gorequireExecutionAccess/requirePlanAccessverify 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.tsrenderPlannedPurchaseRow(~L658-704) gates buttons purely by role permission (canAccess('update','plans'),canAccess('delete','plans')), never by ownership. ThePlannedPurchasepayload built bybuildPlannedPurchase(internal/api/handler_purchases.go~L164) does not includecreated_by_user_id, so the FE has no field to gate ownership on even if it wanted to.Severity rationale
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.Suggested fix (mirror the History ownership model)
execution.created_by_user_id == session.UserID, OR the session holds an-anypermission (admin via wildcard, or a future operator group). Fail closed for non-owners without-any. Reuse the existing pattern fromevaluateCancel*/ retry-own / approve-own inhandler_purchases.go. NULL-creator legacy rows stay out of reach for non--anyusers.created_by_user_idonPlannedPurchase(mirrorPurchaseHistoryRecord.CreatedByUserID).purchase.created_by_user_id === currentUser.id) in addition to the verb check, so non-owners see no actionable buttons.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).