diff --git a/frontend/src/__tests__/dashboard-ownership-950.test.ts b/frontend/src/__tests__/dashboard-ownership-950.test.ts new file mode 100644 index 000000000..335d1ae97 --- /dev/null +++ b/frontend/src/__tests__/dashboard-ownership-950.test.ts @@ -0,0 +1,198 @@ +/** + * Issue #950 follow-up: creator-scope ownership gating on the dashboard + * upcoming-purchases widget's Cancel buttons. + * + * The pre-fix dashboard widget rendered a "Cancel" button on every + * upcoming-purchase card (and a "Cancel Purchase" button in the + * View Details modal) regardless of who created the underlying + * execution. Clicking it called DELETE /api/purchases/planned/{id}, + * which the backend now (correctly) 403s for non-owners after PR #995. + * The result was a UX hole: the operator sees the button, clicks it, + * and gets a confusing failure toast. + * + * The fix gates both buttons on canCancelUpcomingPurchase(), which + * mirrors plans.ts's canManageScheduledPurchase: admin / update-any + * see Cancel on any row; otherwise the row's created_by_user_id must + * match the current user (legacy NULL-creator rows are out of reach + * for non-privileged users). + * + * These tests drive the real renderUpcomingPurchases pipe via + * loadDashboard() so the production gate is what's exercised, not a + * unit shim around the helper. + */ + +// Chart.js + recommendations + freshness need to be mocked before the +// dashboard import the same way dashboard.test.ts does. +const mockShowToast = jest.fn<{ dismiss: () => void }, [unknown]>(() => ({ dismiss: jest.fn() })); +jest.mock('../toast', () => ({ + showToast: (opts: unknown) => mockShowToast(opts), +})); +jest.mock('../confirmDialog', () => ({ + confirmDialog: jest.fn(() => Promise.resolve(true)), +})); +jest.mock('chart.js', () => { + const MockChart = jest.fn().mockImplementation(() => ({ destroy: jest.fn() })); + (MockChart as unknown as { register: jest.Mock }).register = jest.fn(); + return { Chart: MockChart, registerables: [] }; +}); +jest.mock('../recommendations', () => ({ + groupRecsByCell: jest.fn(() => new Map()), + pageLevelRange: jest.fn(() => ({ savingsMin: 0, savingsMax: 0, cellCount: 0 })), + formatSavingsRange: jest.fn((min: number, max: number) => `$${min}-$${max}`), + triggerAutoRefreshIfStale: jest.fn(() => Promise.resolve()), +})); + +jest.mock('../api', () => ({ + getDashboardSummary: jest.fn().mockResolvedValue({ potential_monthly_savings: 0, by_service: {} }), + getUpcomingPurchases: jest.fn(), + getPurchaseDetails: jest.fn(), + cancelPurchase: jest.fn(), + deletePlannedPurchase: jest.fn().mockResolvedValue({}), + deletePlan: jest.fn(), + listAccounts: jest.fn().mockResolvedValue([]), + getSavingsAnalytics: jest.fn().mockResolvedValue({ data_points: [] }), + getRecommendations: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../state', () => ({ + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + getSavingsChart: jest.fn().mockReturnValue(null), + setSavingsChart: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn(), +})); + +jest.mock('../utils', () => ({ + formatCurrency: jest.fn((val) => `$${val || 0}`), + getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), + escapeHtml: jest.fn((str) => str || ''), + populateAccountFilter: jest.fn(() => Promise.resolve()), +})); + +import { loadDashboard } from '../dashboard'; +import * as api from '../api'; +import * as state from '../state'; + +const CREATOR_ID = 'creator-aaaa'; +const OTHER_ID = 'other-bbbb'; +const ADMIN_GROUP = '00000000-0000-5000-8000-000000000001'; + +const ownedPurchase = { + execution_id: 'exec-1', + plan_id: 'plan-1', + plan_name: 'Owned Plan', + scheduled_date: '2026-06-01', + provider: 'aws', + service: 'ec2', + step_number: 1, + total_steps: 4, + estimated_savings: 100, + created_by_user_id: CREATOR_ID, +}; + +const legacyPurchase = { + ...ownedPurchase, + execution_id: 'exec-legacy', + created_by_user_id: undefined as string | undefined, +}; + +type StubUserOpts = { updateAny?: boolean; admin?: boolean; deletePurchases?: boolean }; +const setUser = (id: string, opts: StubUserOpts = {}) => { + const effectivePermissions: Array<{ action: string; resource: string }> = []; + if (opts.admin) { + effectivePermissions.push({ action: 'admin', resource: '*' }); + } else if (opts.deletePurchases !== false) { + // Standard user holds delete:purchases (PR #660 default). + effectivePermissions.push({ action: 'delete', resource: 'purchases' }); + } + if (opts.updateAny) { + effectivePermissions.push({ action: 'update-any', resource: 'purchases' }); + } + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id, + email: `${id}@example.com`, + groups: opts.admin ? [ADMIN_GROUP] : [], + effectivePermissions, + }); +}; + +const setupDom = () => { + document.body.innerHTML = ` +
+
+ + +
+
+ `; +}; + +const cancelBtns = () => + document.querySelectorAll('[data-action="cancel-purchase"]'); + +describe('Dashboard upcoming-purchase ownership gating (issue #950)', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDom(); + }); + + test('creator sees the Cancel button on their own scheduled purchase', async () => { + setUser(CREATOR_ID); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(1); + }); + + test('non-creator with the same verbs sees NO Cancel button (the bug)', async () => { + setUser(OTHER_ID); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(0); + // The card still renders -- the operator sees the row and can click + // "View Details", which is intentionally unrestricted. + const viewBtns = document.querySelectorAll('[data-action="view-purchase"]'); + expect(viewBtns).toHaveLength(1); + }); + + test('update-any holder sees Cancel on another user\'s scheduled purchase', async () => { + setUser(OTHER_ID, { updateAny: true }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(1); + }); + + test('admin sees Cancel on every row (admin:* covers delete:purchases)', async () => { + setUser(OTHER_ID, { admin: true }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ + purchases: [ownedPurchase, { ...ownedPurchase, execution_id: 'exec-2' }], + }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(2); + }); + + test('legacy NULL-creator row shows no Cancel for a non-update-any user', async () => { + setUser(CREATOR_ID); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [legacyPurchase] }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(0); + }); + + test('user without delete:purchases sees no Cancel even on their own row', async () => { + // Read-only style: holds neither delete:purchases nor admin nor + // update-any. Even on their own row the button must stay hidden + // because the backend would reject the click on verb grounds. + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id: CREATOR_ID, + email: 'ro@example.com', + groups: [], + effectivePermissions: [{ action: 'view', resource: 'purchases' }], + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadDashboard(); + expect(cancelBtns()).toHaveLength(0); + }); +}); diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 7b35dd964..91a6bd7d3 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -87,6 +87,17 @@ jest.mock('../state', () => ({ // during setup to register its reload callback. subscribeProvider: jest.fn().mockReturnValue(() => {}), subscribeAccount: jest.fn().mockReturnValue(() => {}), + // Issue #950: the dashboard upcoming-purchase widget now gates the + // Cancel button on creator-scope ownership (canCancelUpcomingPurchase + // -> canAccess + getCurrentUser). Default the session to an admin so + // the pre-#950 cancel-flow tests below keep exercising the click path; + // the dedicated ownership tests override this per-test. + getCurrentUser: jest.fn().mockReturnValue({ + id: 'admin-user', + email: 'admin@example.com', + groups: ['00000000-0000-5000-8000-000000000001'], + effectivePermissions: [{ action: 'admin', resource: '*' }], + }), })); // Mock utils diff --git a/frontend/src/__tests__/plans-ownership-950.test.ts b/frontend/src/__tests__/plans-ownership-950.test.ts new file mode 100644 index 000000000..33a9195e8 --- /dev/null +++ b/frontend/src/__tests__/plans-ownership-950.test.ts @@ -0,0 +1,169 @@ +/** + * Issue #950: creator-scope ownership gating on Scheduled (Planned) Purchase + * row action buttons. + * + * The pre-fix behaviour gated the Run / Pause / Resume / Edit / Disable + * buttons purely on the plan-management verbs (update:plans / delete:plans), + * so a Standard user with those verbs saw actionable buttons on scheduled + * purchases created by OTHER users. The fix ANDs in + * canManageScheduledPurchase(): a non-creator without update-any:purchases + * sees NO action buttons. + * + * These tests drive the real loadPlans() render path. They set + * effectivePermissions on the mock user so the #365 verb gate passes, + * isolating the new ownership gate as the deciding factor. + */ +import { loadPlans } from '../plans'; + +jest.mock('../api', () => ({ + getPlans: jest.fn(), + getPlannedPurchases: jest.fn(), + listPlanAccounts: jest.fn().mockResolvedValue([]), +})); + +jest.mock('../state', () => ({ + getRecommendations: jest.fn().mockReturnValue([]), + getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()), + getVisibleRecommendations: jest.fn().mockReturnValue([]), + setVisibleRecommendations: jest.fn(), + getCurrentProvider: jest.fn().mockReturnValue(''), + setCurrentProvider: jest.fn(), + getCurrentAccountIDs: jest.fn().mockReturnValue([]), + setCurrentAccountIDs: jest.fn(), + subscribeProvider: jest.fn().mockReturnValue(() => {}), + subscribeAccount: jest.fn().mockReturnValue(() => {}), + getCurrentUser: jest.fn(), + getPlansColumnFilters: jest.fn().mockReturnValue({}), + setPlansColumnFilter: jest.fn(), + clearAllPlansColumnFilters: jest.fn(), +})); + +jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); + +import * as api from '../api'; +import * as state from '../state'; + +const CREATOR_ID = 'creator-aaaa'; +const OTHER_ID = 'other-bbbb'; + +const samplePlan = { + id: 'plan-1', + name: 'Sample Plan', + enabled: true, + auto_purchase: true, + services: { + ec2: { provider: 'aws', service: 'ec2', enabled: true, term: 1, payment: 'all-upfront', coverage: 80 }, + }, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0, current_step: 1, total_steps: 4 }, +}; + +// A scheduled purchase created by CREATOR_ID. +const ownedPurchase = { + id: 'pp-1', + plan_id: 'plan-1', + plan_name: 'Sample Plan', + scheduled_date: '2026-06-01T00:00:00Z', + provider: 'aws', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 5, + term: 1, + payment: 'all-upfront', + upfront_cost: 100, + estimated_savings: 50, + step_number: 1, + total_steps: 4, + status: 'pending', + created_by_user_id: CREATOR_ID, +}; + +// Legacy row with no creator (pre-migration NULL). +const legacyPurchase = { ...ownedPurchase, created_by_user_id: undefined as string | undefined }; + +// A user holding the plan-management verbs + update:purchases (so the #365 +// verb gate passes), optionally update-any:purchases. id identifies the +// session user for the ownership comparison. +const setUser = (id: string, opts: { updateAny?: boolean } = {}) => { + const effectivePermissions = [ + { action: 'update', resource: 'plans' }, + { action: 'delete', resource: 'plans' }, + { action: 'execute', resource: 'purchases' }, + { action: 'update', resource: 'purchases' }, + { action: 'delete', resource: 'purchases' }, + ]; + if (opts.updateAny) { + effectivePermissions.push({ action: 'update-any', resource: 'purchases' }); + } + (state.getCurrentUser as jest.Mock).mockReturnValue({ + id, + email: `${id}@example.com`, + groups: [], + effectivePermissions, + }); +}; + +const ppHtml = (): string => (document.getElementById('planned-purchases-list') as HTMLElement).innerHTML; + +const setupDom = () => { + const btn = document.createElement('button'); + btn.id = 'new-plan-btn'; + const list = document.createElement('div'); + list.id = 'plans-list'; + const planned = document.createElement('div'); + planned.id = 'planned-purchases-list'; + document.body.replaceChildren(btn, list, planned); +}; + +const ACTIONS = ['run', 'pause', 'resume', 'edit', 'disable']; + +describe('Scheduled-purchase ownership gating (issue #950)', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDom(); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [samplePlan] }); + }); + + test("creator sees action buttons on their own scheduled purchase", async () => { + setUser(CREATOR_ID); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadPlans(); + const html = ppHtml(); + // run/pause are status-dependent (pending -> run+pause shown). + expect(html).toContain('data-action="run"'); + expect(html).toContain('data-action="pause"'); + expect(html).toContain('data-action="edit"'); + expect(html).toContain('data-action="disable"'); + }); + + test("non-creator with the same verbs sees NO action buttons (the bug)", async () => { + // The deciding factor is ownership: this user holds update:plans / + // delete:plans / update:purchases but did NOT create the row. + setUser(OTHER_ID); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadPlans(); + const html = ppHtml(); + ACTIONS.forEach((act) => expect(html).not.toContain(`data-action="${act}"`)); + // The row itself still renders (status badge visible), just no buttons. + expect(html).toContain('Sample Plan'); + }); + + test("update-any holder sees buttons on another user's scheduled purchase", async () => { + setUser(OTHER_ID, { updateAny: true }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [ownedPurchase] }); + await loadPlans(); + const html = ppHtml(); + expect(html).toContain('data-action="run"'); + expect(html).toContain('data-action="pause"'); + expect(html).toContain('data-action="edit"'); + expect(html).toContain('data-action="disable"'); + }); + + test("legacy NULL-creator row shows no buttons for a non-update-any user", async () => { + setUser(CREATOR_ID); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [legacyPurchase] }); + await loadPlans(); + const html = ppHtml(); + ACTIONS.forEach((act) => expect(html).not.toContain(`data-action="${act}"`)); + }); +}); diff --git a/frontend/src/__tests__/plans-permissions.test.ts b/frontend/src/__tests__/plans-permissions.test.ts index 89168d7c5..7e97929dd 100644 --- a/frontend/src/__tests__/plans-permissions.test.ts +++ b/frontend/src/__tests__/plans-permissions.test.ts @@ -44,7 +44,7 @@ jest.mock('../history', () => ({ viewPlanHistory: jest.fn() })); import * as api from '../api'; import * as state from '../state'; -import { ADMINISTRATORS_GROUP_ID } from '../permissions'; +import { ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID } from '../permissions'; const samplePlan = { id: 'plan-1', @@ -78,7 +78,7 @@ const samplePlannedPurchase = { const mockUser = (role: string | null) => { (state.getCurrentUser as jest.Mock).mockReturnValue( - role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? [ADMINISTRATORS_GROUP_ID] : [] }, + role === null ? null : { id: 'u', email: 'u@example.com', groups: role === 'admin' ? [ADMINISTRATORS_GROUP_ID, PURCHASER_GROUP_ID] : [] }, ); }; diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index e297cc445..95d5ea285 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -57,7 +57,11 @@ jest.mock('../state', () => ({ // factories are hoisted before imports; jest.requireActual also fails here // because permissions.ts has a top-level import of ./state which is the very // module being mocked (circular init). permissions.test.ts pins the value. - getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }), + // Include PURCHASER_GROUP_ID literal alongside ADMINISTRATORS_GROUP_ID so + // execute:purchases (carved out of admin:*) resolves true for the default + // admin session. Both are literals to avoid circular-init issues (see + // comment above). Matches PURCHASER_GROUP_ID from permissions.ts. + getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001', '00000000-0000-5000-8000-000000000007'] }), // Issue #166 follow-up: plans.ts now reads per-column filter state via // these accessors. Default to "no filters" so legacy assertions keep // passing; tests that exercise the popover override per-case. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 77ebab5cf..f56a98f56 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -107,6 +107,13 @@ export interface UpcomingPurchase { step_number: number; total_steps: number; estimated_savings: number; + // created_by_user_id is the UUID of the user who scheduled the + // execution, propagated by the backend so the dashboard's "Cancel" + // button can apply the same creator-scope ownership gate the Plans + // page uses (issue #950). Optional because legacy / scheduler-tick + // rows ship NULL here; the gate treats undefined as "not the current + // user" and hides the button for non-update-any callers. + created_by_user_id?: string; } // Recommendation types @@ -370,6 +377,11 @@ export interface PlannedPurchase { status: 'pending' | 'paused' | 'running' | 'completed' | 'failed'; step_number: number; total_steps: number; + // created_by_user_id is the UUID of the user who scheduled the purchase. + // The row action buttons are gated on creator-scope ownership (issue #950): + // a non-creator without update-any:purchases sees no actionable buttons. + // Omitted (undefined) for legacy rows with a NULL creator. + created_by_user_id?: string; } // User Management Types diff --git a/frontend/src/dashboard.ts b/frontend/src/dashboard.ts index e3bab9fc7..6c03c6dc6 100644 --- a/frontend/src/dashboard.ts +++ b/frontend/src/dashboard.ts @@ -10,6 +10,7 @@ import type { DashboardSummary, UpcomingPurchase, ServiceSavings, LocalRecommend import type { SavingsDataPoint } from './api'; import { showToast } from './toast'; import { confirmDialog } from './confirmDialog'; +import { canAccess } from './permissions'; import { groupRecsByCell, pageLevelRange, formatSavingsRange, triggerAutoRefreshIfStale } from './recommendations'; import { showSkeletonTiles, showSkeletonBlock, teardownSkeleton } from './lib/skeleton'; @@ -373,6 +374,31 @@ function attachSparkline(key: string, values: readonly number[]): void { export const __test__ = { sparklinePoints, attachSparkline, computeServiceStats }; +// canCancelUpcomingPurchase returns true when the current session is +// permitted to cancel the given upcoming purchase via the Dashboard +// widget (issue #950). UX gate only -- the backend +// authorizeExecutionManagement in internal/api/handler_purchases.go +// remains the security boundary; a false-positive surfaces as a 403 +// toast rather than a successful mutation. +// +// Mirrors canManageScheduledPurchase in plans.ts so the Plans page and +// the Dashboard widget agree on which Cancel buttons appear: +// * admin (admin:*) or update-any:purchases -> can cancel any row; +// * otherwise the row's created_by_user_id must match the current user; +// * legacy / scheduler-tick rows with undefined created_by_user_id -> +// no Cancel button for non-privileged users (out of reach without +// update-any). +// Additionally requires the base delete:purchases verb the backend +// handler asks for, mirroring the gate on the Plans page disable button. +export function canCancelUpcomingPurchase(purchase: UpcomingPurchase): boolean { + if (!canAccess('delete', 'purchases')) return false; + if (canAccess('admin', '*') || canAccess('update-any', 'purchases')) return true; + const user = state.getCurrentUser(); + if (!user) return false; + if (!purchase.created_by_user_id) return false; + return purchase.created_by_user_id === user.id; +} + function renderUpcomingPurchases(purchases: UpcomingPurchase[]): void { const container = document.getElementById('upcoming-list'); if (!container) return; @@ -447,13 +473,20 @@ function renderUpcomingPurchases(purchases: UpcomingPurchase[]): void { viewBtn.dataset['action'] = 'view-purchase'; viewBtn.dataset['id'] = String(p.execution_id); viewBtn.textContent = 'View Details'; - const cancelBtn = document.createElement('button'); - cancelBtn.dataset['action'] = 'cancel-purchase'; - cancelBtn.dataset['id'] = String(p.execution_id); - cancelBtn.className = 'danger'; - cancelBtn.textContent = 'Cancel'; actions.appendChild(viewBtn); - actions.appendChild(cancelBtn); + // Issue #950: Cancel routes to DELETE /api/purchases/planned/{id}, + // which the backend now gates on creator-scope ownership. Hide the + // button when the current session is not authorised so the operator + // doesn't get a 403 toast on click. Plans page applies the same gate + // via canManageScheduledPurchase + canAccess('delete','purchases'). + if (canCancelUpcomingPurchase(p)) { + const cancelBtn = document.createElement('button'); + cancelBtn.dataset['action'] = 'cancel-purchase'; + cancelBtn.dataset['id'] = String(p.execution_id); + cancelBtn.className = 'danger'; + cancelBtn.textContent = 'Cancel'; + actions.appendChild(cancelBtn); + } card.appendChild(info); card.appendChild(savings); @@ -545,30 +578,36 @@ function buildUpcomingDetailsModal(p: UpcomingPurchase, executionId: string): HT btnRow.className = 'modal-buttons'; content.appendChild(btnRow); - const cancelBtn = document.createElement('button'); - cancelBtn.type = 'button'; - cancelBtn.id = 'cancel-purchase-detail-btn'; - cancelBtn.className = 'danger'; - cancelBtn.textContent = 'Cancel Purchase'; - cancelBtn.addEventListener('click', async () => { - const ok = await confirmDialog({ - title: 'Cancel this scheduled purchase?', - body: 'Cancelling a scheduled purchase cannot be undone. Any upfront cost already committed will not be refunded.', - confirmLabel: 'Cancel purchase', - destructive: true, + // Issue #950: gate the Cancel button on the same ownership check as + // the card-level button above. Users land here from "View Details", + // which is visible to everyone; only the destructive action needs the + // creator-scope gate. + if (canCancelUpcomingPurchase(p)) { + const cancelBtn = document.createElement('button'); + cancelBtn.type = 'button'; + cancelBtn.id = 'cancel-purchase-detail-btn'; + cancelBtn.className = 'danger'; + cancelBtn.textContent = 'Cancel Purchase'; + cancelBtn.addEventListener('click', async () => { + const ok = await confirmDialog({ + title: 'Cancel this scheduled purchase?', + body: 'Cancelling a scheduled purchase cannot be undone. Any upfront cost already committed will not be refunded.', + confirmLabel: 'Cancel purchase', + destructive: true, + }); + if (!ok) return; + try { + await api.deletePlannedPurchase(executionId); + modal.remove(); + await loadDashboard(); + showToast({ message: 'Purchase cancelled successfully', kind: 'success', timeout: 5_000 }); + } catch (cancelError) { + console.error('Failed to cancel purchase:', cancelError); + showToast({ message: 'Failed to cancel purchase', kind: 'error' }); + } }); - if (!ok) return; - try { - await api.deletePlannedPurchase(executionId); - modal.remove(); - await loadDashboard(); - showToast({ message: 'Purchase cancelled successfully', kind: 'success', timeout: 5_000 }); - } catch (cancelError) { - console.error('Failed to cancel purchase:', cancelError); - showToast({ message: 'Failed to cancel purchase', kind: 'error' }); - } - }); - btnRow.appendChild(cancelBtn); + btnRow.appendChild(cancelBtn); + } const closeBtn = document.createElement('button'); closeBtn.type = 'button'; diff --git a/frontend/src/permissions.ts b/frontend/src/permissions.ts index fecf06d46..b3473f4b7 100644 --- a/frontend/src/permissions.ts +++ b/frontend/src/permissions.ts @@ -49,6 +49,10 @@ export type Action = | 'approve-any' | 'execute-own' | 'execute-any' + // update-any:purchases lets a holder manage (pause/resume/run/delete) + // ANY user's scheduled purchase, bypassing the creator-scope ownership + // check (issue #950). Mirrors cancel-any/approve-any on History rows. + | 'update-any' | 'admin'; // Resource names. Closed enum for the same reason. diff --git a/frontend/src/plans.ts b/frontend/src/plans.ts index 8834ad6cb..f1632b5c3 100644 --- a/frontend/src/plans.ts +++ b/frontend/src/plans.ts @@ -652,6 +652,26 @@ function rerenderPlannedPurchases(): void { renderPlannedPurchasesInternal(); } +// canManageScheduledPurchase returns true when the current session is +// permitted to act on the given scheduled purchase's row buttons (issue #950). +// UX gate only -- the backend authorizeExecutionManagement in +// internal/api/handler_purchases.go remains the security boundary; a +// false-positive here surfaces as a 403 toast on click rather than a +// successful mutation. +// +// Heuristic (mirrors the creator-scope model on History rows): +// * admin (admin:* wildcard) or update-any:purchases -> manage anyone's row; +// * otherwise the row's created_by_user_id must match the current user; +// * legacy rows with a NULL created_by_user_id -> no buttons for non- +// privileged users (out of reach without update-any). +function canManageScheduledPurchase(purchase: PlannedPurchase): boolean { + if (canAccess('admin', '*') || canAccess('update-any', 'purchases')) return true; + const user = state.getCurrentUser(); + if (!user) return false; + if (!purchase.created_by_user_id) return false; + return purchase.created_by_user_id === user.id; +} + /** * Render a single planned purchase row */ @@ -674,8 +694,17 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { // a click on each button would require. Readonly users see no buttons // (status badge only); user role sees Run/Pause/Resume/Edit but not // Disable; admins see everything. - const canManagePlan = canAccess('update', 'plans'); - const canDisablePlan = canAccess('delete', 'plans'); + // + // Issue #950: AND in creator-scope ownership. A non-creator who lacks + // update-any:purchases (a standard user looking at someone else's row) + // sees NO action buttons, mirroring the backend ownership gate. This is + // a UX gate; the backend authorizeExecutionManagement is the real + // boundary. + const canManagePurchase = canManageScheduledPurchase(purchase); + const canRunPurchase = canManagePurchase && canAccess('execute', 'purchases') && canRun; + const canPauseOrResumePurchase = canManagePurchase && canAccess('update', 'purchases'); + const canEditPlan = canManagePurchase && canAccess('update', 'plans'); + const canDisablePlan = canManagePurchase && canAccess('delete', 'purchases'); return ` @@ -693,10 +722,10 @@ function renderPlannedPurchaseRow(purchase: PlannedPurchase): string { ${formatCurrency(purchase.estimated_savings)}/mo ${escapeHtml(purchase.status)} - ${canManagePlan && canRun ? `` : ''} - ${canManagePlan && isPending ? `` : ''} - ${canManagePlan && isPaused ? `` : ''} - ${canManagePlan ? `` : ''} + ${canRunPurchase ? `` : ''} + ${canPauseOrResumePurchase && isPending ? `` : ''} + ${canPauseOrResumePurchase && isPaused ? `` : ''} + ${canEditPlan ? `` : ''} ${canDisablePlan ? `` : ''} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5b37e7fe3..0413014d1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -47,6 +47,11 @@ export interface UpcomingPurchase { step_number: number; total_steps: number; estimated_savings: number; + // created_by_user_id mirrors the field on api.UpcomingPurchase so the + // dashboard widget can apply the issue-#950 creator-scope ownership + // gate on the Cancel button. Optional: legacy / scheduler-tick rows + // ship NULL here. + created_by_user_id?: string; } // Recommendations types diff --git a/internal/api/handler_dashboard.go b/internal/api/handler_dashboard.go index 2321355dd..56a897f8a 100644 --- a/internal/api/handler_dashboard.go +++ b/internal/api/handler_dashboard.go @@ -394,6 +394,7 @@ func upcomingFromExecution(plan *config.PurchasePlan, exec *config.PurchaseExecu StepNumber: exec.StepNumber, TotalSteps: plan.RampSchedule.TotalSteps, EstimatedSavings: exec.EstimatedSavings, + CreatedByUserID: exec.CreatedByUserID, } } diff --git a/internal/api/handler_dashboard_test.go b/internal/api/handler_dashboard_test.go index ec13d43e9..181b75f63 100644 --- a/internal/api/handler_dashboard_test.go +++ b/internal/api/handler_dashboard_test.go @@ -429,6 +429,66 @@ func TestHandler_getUpcomingPurchases(t *testing.T) { assert.Equal(t, 2, second.StepNumber) } +// TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID is the +// issue-#950 follow-up regression: the dashboard widget on the frontend +// applies a creator-scope ownership gate on the Cancel button (mirrors +// the Plans page); it can only do so if the backend ships +// created_by_user_id on every row. Pre-fix the field was absent, so the +// widget defaulted to "no owner known" and either showed Cancel for +// everyone (when ungated) or for nobody (when gated) -- both wrong. +func TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + + scheduled := time.Now().AddDate(0, 0, 7) + plan := config.PurchasePlan{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Owned Plan", + Enabled: true, + Services: map[string]config.ServiceConfig{ + "aws/ec2": {Provider: "aws", Service: "ec2"}, + }, + RampSchedule: config.RampSchedule{TotalSteps: 4}, + } + creator := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + pending := []config.PurchaseExecution{ + { + ExecutionID: "11112222-3333-4444-5555-666677778888", + PlanID: plan.ID, + Status: "pending", + ScheduledDate: scheduled, + StepNumber: 1, + CreatedByUserID: &creator, + }, + { + // Legacy / scheduler-tick row: NULL creator. Must serialise as + // no created_by_user_id field (omitempty on the JSON tag) so + // the frontend treats it as out-of-reach for non-update-any + // users -- the documented #950 behaviour. + ExecutionID: "99998888-7777-6666-5555-444433332222", + PlanID: plan.ID, + Status: "pending", + ScheduledDate: scheduled.AddDate(0, 0, 7), + StepNumber: 2, + CreatedByUserID: nil, + }, + } + + mockStore.On("GetPendingExecutions", ctx).Return(pending, nil) + mockStore.On("ListPurchasePlans", ctx, config.PurchasePlanFilter{}).Return([]config.PurchasePlan{plan}, nil) + + mockAuth, req := adminDashboardReq(ctx) + handler := &Handler{auth: mockAuth, config: mockStore} + + result, err := handler.getUpcomingPurchases(ctx, req) + require.NoError(t, err) + require.Len(t, result.Purchases, 2) + + require.NotNil(t, result.Purchases[0].CreatedByUserID, "owned-row CreatedByUserID must propagate") + assert.Equal(t, creator, *result.Purchases[0].CreatedByUserID) + assert.Nil(t, result.Purchases[1].CreatedByUserID, "legacy NULL-creator row must stay nil") +} + // TestHandler_getUpcomingPurchases_OrphanExecutionSkipped guards against the // "execution row with deleted parent plan" cleanup-gap edge case: rather // than crash, the widget hides the orphan. Cleanup is a separate concern. diff --git a/internal/api/handler_plans.go b/internal/api/handler_plans.go index 15f7cdb58..eed686d06 100644 --- a/internal/api/handler_plans.go +++ b/internal/api/handler_plans.go @@ -289,9 +289,20 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La // pointer). A retry would then duplicate rows 1-3. WithTx makes // both classes of corruption impossible — the caller can safely // retry on transient errors knowing nothing was committed. + // + // Issue #950: stamp the session user onto each new execution's + // created_by_user_id so the per-row creator-scope ownership gate + // (authorizeExecutionManagement) recognises the actor who scheduled + // the purchases as their owner. Without this the rows ship NULL and + // are unreachable for pause / resume / run / delete by anyone except + // admins / update-any holders, including the user who just clicked + // "Create planned purchases" for their own plan. Admin-API-key and + // non-UUID sessions resolve to nil, matching the executePurchase / + // retry paths and the migration-000041 fail-closed policy. + creator := resolveCreatorUserID(session) created := 0 if err := h.config.WithTx(ctx, func(tx pgx.Tx) error { - n, txErr := h.createPurchaseExecutionsTx(ctx, tx, plan, planID, req.Count, startDate) + n, txErr := h.createPurchaseExecutionsTx(ctx, tx, plan, planID, req.Count, startDate, creator) if txErr != nil { return txErr } @@ -344,7 +355,14 @@ func (h *Handler) getPlanForPurchaseCreation(ctx context.Context, planID string) // Returns the number of rows that would have been committed had the // loop completed — used for the user-visible response on success; // undefined (and unused) on error since the rollback voids them all. -func (h *Handler) createPurchaseExecutionsTx(ctx context.Context, tx pgx.Tx, plan *config.PurchasePlan, planID string, count int, startDate time.Time) (int, error) { +// +// creator carries the session user's UUID (or nil for the admin-API-key / +// non-UUID-session paths) and is stamped onto every inserted row's +// created_by_user_id so the issue-#950 ownership gate downstream can +// recognise the actor as the rightful manager. A nil value mirrors the +// migration-000041 fail-closed semantics: legacy / unattributed rows are +// reachable only by admin / update-any holders. +func (h *Handler) createPurchaseExecutionsTx(ctx context.Context, tx pgx.Tx, plan *config.PurchasePlan, planID string, count int, startDate time.Time, creator *string) (int, error) { intervalDays := plan.RampSchedule.StepIntervalDays if intervalDays == 0 { intervalDays = 7 // Default to weekly if not set @@ -359,12 +377,13 @@ func (h *Handler) createPurchaseExecutionsTx(ctx context.Context, tx pgx.Tx, pla return created, fmt.Errorf("failed to generate approval token (row %d/%d): %w", created+1, count, err) } execution := &config.PurchaseExecution{ - PlanID: planID, - ExecutionID: uuid.New().String(), - Status: "pending", - StepNumber: plan.RampSchedule.CurrentStep + i + 1, - ScheduledDate: scheduledDate, - ApprovalToken: approvalToken, + PlanID: planID, + ExecutionID: uuid.New().String(), + Status: "pending", + StepNumber: plan.RampSchedule.CurrentStep + i + 1, + ScheduledDate: scheduledDate, + ApprovalToken: approvalToken, + CreatedByUserID: creator, } if err := h.config.SavePurchaseExecutionTx(ctx, tx, execution); err != nil { diff --git a/internal/api/handler_plans_test.go b/internal/api/handler_plans_test.go index 40bdf2387..3f7e3e29b 100644 --- a/internal/api/handler_plans_test.go +++ b/internal/api/handler_plans_test.go @@ -445,6 +445,117 @@ func TestHandler_createPlannedPurchases(t *testing.T) { assert.Equal(t, 3, result.Created) } +// TestHandler_createPlannedPurchases_StampsCreator is the issue-#950 regression +// guard: every execution row written through POST /api/plans/{id}/purchases +// MUST carry the session user's UUID in CreatedByUserID, otherwise the +// per-row ownership gate (authorizeExecutionManagement in +// handler_purchases.go) downstream cannot recognise the actor as the +// rightful manager and the user who just scheduled the purchases is +// locked out of pause / resume / run / delete until an admin steps in. +// +// Pre-fix the field shipped zero-valued (nil pointer), making every +// freshly scheduled row look like a legacy unattributed entry. +func TestHandler_createPlannedPurchases_StampsCreator(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + const userID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + userSession := &Session{UserID: userID, Email: "u@example.com"} + + plan := &config.PurchasePlan{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Test Plan", + RampSchedule: config.RampSchedule{ + StepIntervalDays: 7, + CurrentStep: 0, + }, + } + + mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession, nil) + mockAuth.grantAdmin() + mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) + + // Capture every saved execution's CreatedByUserID so we can assert + // the field is stamped on each row in the batch (not just the first). + var savedCreators []*string + mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")). + Run(func(args mock.Arguments) { + exec := args.Get(1).(*config.PurchaseExecution) + savedCreators = append(savedCreators, exec.CreatedByUserID) + }). + Return(nil).Times(3) + mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + + handler := &Handler{config: mockStore, auth: mockAuth} + + body := `{"count": 3, "start_date": "2024-12-01"}` + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer user-token"}, + Body: body, + } + result, err := handler.createPlannedPurchases(ctx, req, "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + assert.Equal(t, 3, result.Created) + + require.Len(t, savedCreators, 3, "expected 3 saved executions") + for i, c := range savedCreators { + require.NotNil(t, c, "execution %d shipped a nil CreatedByUserID (issue #950 regression)", i) + assert.Equal(t, userID, *c, "execution %d shipped the wrong CreatedByUserID", i) + } +} + +// TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil locks in that +// the stateless admin-API-key path (UserID == apiKeyAdminUserID, not a UUID) +// stamps NULL rather than the literal sentinel. resolveCreatorUserID rejects +// non-UUID UserIDs so the FK to users stays valid; the row falls through to +// the admin / update-any management path exactly like a legacy scheduler- +// created row would. +func TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + + apiKeySession := &Session{UserID: apiKeyAdminUserID, Email: "admin-api-key"} + + plan := &config.PurchasePlan{ + ID: "11111111-1111-1111-1111-111111111111", + Name: "Test Plan", + RampSchedule: config.RampSchedule{ + StepIntervalDays: 7, + CurrentStep: 0, + }, + } + + mockAuth.On("ValidateSession", ctx, "api-key").Return(apiKeySession, nil) + mockAuth.grantAdmin() + mockStore.On("GetPurchasePlan", ctx, "11111111-1111-1111-1111-111111111111").Return(plan, nil) + + var savedCreators []*string + mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")). + Run(func(args mock.Arguments) { + exec := args.Get(1).(*config.PurchaseExecution) + savedCreators = append(savedCreators, exec.CreatedByUserID) + }). + Return(nil).Times(2) + mockStore.On("UpdatePurchasePlan", ctx, mock.AnythingOfType("*config.PurchasePlan")).Return(nil) + + handler := &Handler{config: mockStore, auth: mockAuth} + + body := `{"count": 2, "start_date": "2024-12-01"}` + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer api-key"}, + Body: body, + } + _, err := handler.createPlannedPurchases(ctx, req, "11111111-1111-1111-1111-111111111111") + require.NoError(t, err) + + require.Len(t, savedCreators, 2) + for i, c := range savedCreators { + assert.Nil(t, c, "execution %d should ship a nil CreatedByUserID for the admin-API-key path", i) + } +} + // TestHandler_createPlannedPurchases_MidLoopFailureRollsBack verifies // the partial-failure regression CodeRabbit flagged: a save failure on // row N must NOT leave rows 1..N-1 persisted (they would be retried as diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 6df17dea0..397d6db75 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -188,9 +188,65 @@ func buildPlannedPurchase(plan *config.PurchasePlan, exec *config.PurchaseExecut Status: exec.Status, StepNumber: exec.StepNumber, TotalSteps: plan.RampSchedule.TotalSteps, + CreatedByUserID: exec.CreatedByUserID, } } +// authorizeExecutionManagement enforces creator-scope ownership on the +// scheduled-purchase management handlers (pause / resume / run / delete), +// 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. +// +// Gate logic (mirrors authorizeSessionCancel / authorizeSessionApprove): +// - stateless admin API key: always permitted (apiKeyAdminUserID sentinel). +// - update-any:purchases: permitted regardless of creator. Administrators- +// group users pass here because {admin, *} matches ActionUpdateAny +// (update-any is not in adminCarvedOuts). +// - otherwise: permitted only when the execution's CreatedByUserID is +// non-nil and equals a non-empty session.UserID (the caller created it). +// Legacy rows with a NULL creator are out of reach for non-update-any +// users, matching the cancel-own / approve-own / retry-own model. +// - any other case: 403 fail-closed. A nil auth component is a 500 per +// feedback_fail_closed_middleware.md. +// +// Only fetches the execution on the creator-match path: admin and update-any +// callers are authorised without a store round-trip (and admin sessions have +// unrestricted access, so requireExecutionAccess skipped the fetch too). +func (h *Handler) authorizeExecutionManagement(ctx context.Context, session *Session, executionID string) error { + if session.UserID == apiKeyAdminUserID { + return nil + } + if h.auth == nil { + return NewClientError(500, "authentication service not configured") + } + + hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionUpdateAny, auth.ResourcePurchases) + if err != nil { + return fmt.Errorf("permission check failed: %w", err) + } + if hasAny { + return nil + } + + execution, err := h.config.GetExecutionByID(ctx, executionID) + if err != nil { + return fmt.Errorf("failed to get execution: %w", err) + } + if execution == nil { + return errNotFound + } + + // Creator match: both IDs must be non-empty and equal. An empty-string + // collision (legacy NULL creator + missing session UserID) must not + // grant access. + if session.UserID == "" || execution.CreatedByUserID == nil || *execution.CreatedByUserID != session.UserID { + return NewClientError(403, "permission denied: cannot manage another user's scheduled purchase") + } + return nil +} + func (h *Handler) pausePlannedPurchase(ctx context.Context, req *events.LambdaFunctionURLRequest, executionID string) (*StatusResponse, error) { if err := validateUUID(executionID); err != nil { return nil, err @@ -203,6 +259,9 @@ 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.authorizeExecutionManagement(ctx, session, executionID); err != nil { + return nil, err + } // Atomically transition to paused if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "running"}, "paused"); err != nil { @@ -224,6 +283,9 @@ 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.authorizeExecutionManagement(ctx, session, executionID); err != nil { + return nil, err + } // Atomically transition from paused back to pending if _, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"paused"}, "pending"); err != nil { @@ -245,6 +307,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 { + return nil, err + } // Atomically transition to running — only one concurrent caller can succeed. // TransitionExecutionStatus handles not-found and wrong-status cases. @@ -271,30 +336,13 @@ func (h *Handler) deletePlannedPurchase(ctx context.Context, req *events.LambdaF if err := h.requireExecutionAccess(ctx, session, executionID); err != nil { return nil, err } + if err := h.authorizeExecutionManagement(ctx, session, executionID); err != nil { + return nil, err + } - // Cancel the scheduled execution. The RETURNING clause gives us the - // parent plan_id so we can disable the plan in the same handler call. - // - // Idempotency: if TransitionExecutionStatus returns - // ErrExecutionNotInExpectedStatus the row is already in a terminal state - // (most likely "cancelled" from a previous attempt). In that case we - // fetch the execution to recover the PlanID and still attempt to disable - // the plan, so a retry never leaves plan.enabled=true. - cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled") + cancelled, err := h.cancelOrRecoverExecution(ctx, executionID) if err != nil { - if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) { - return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err)) - } - // The cancel already landed (e.g. a prior request succeeded and was - // retried). Recover the execution so we can still disable the plan. - existing, getErr := h.config.GetExecutionByID(ctx, executionID) - if getErr != nil { - return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr) - } - if existing == nil { - return nil, NewClientError(404, fmt.Sprintf("execution %s not found", executionID)) - } - cancelled = existing + return nil, err } // Set the parent plan's enabled flag to false so the Plans page toggle @@ -310,6 +358,34 @@ func (h *Handler) deletePlannedPurchase(ctx context.Context, req *events.LambdaF return &StatusResponse{Status: "cancelled"}, nil } +// cancelOrRecoverExecution transitions the execution to "cancelled" if it is +// still in {pending, paused}. If a prior attempt already cancelled it +// (ErrExecutionNotInExpectedStatus), it fetches the row instead so the caller +// can still drive the plan-disable side-effect, keeping the operation +// idempotent across retries. +func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string) (*config.PurchaseExecution, error) { + cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled") + if err == nil { + return cancelled, nil + } + if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) { + return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err)) + } + existing, getErr := h.config.GetExecutionByID(ctx, executionID) + if getErr != nil { + return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr) + } + if existing == nil { + return nil, NewClientError(404, fmt.Sprintf("execution %s not found", executionID)) + } + if existing.Status != "cancelled" { + return nil, NewClientError(409, fmt.Sprintf( + "execution %s cannot be cancelled (status=%s)", + executionID, existing.Status)) + } + return existing, nil +} + // disablePlan fetches the plan identified by planID and sets Enabled=false if // it is currently true. It is idempotent: calling it against an already- // disabled plan is a no-op. Returns a 404 ClientError when the plan does not diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index bebd33576..44f9ec8ae 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -1319,6 +1319,56 @@ func TestHandler_deletePlannedPurchase_ConflictRetryAlreadyDisabled(t *testing.T assert.Equal(t, "cancelled", result.Status) } +// TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409 is a +// regression test for CR #995 Finding 1: when TransitionExecutionStatus +// returns ErrExecutionNotInExpectedStatus but the fetched row is NOT +// "cancelled" (e.g. the execution raced to "running"), cancelOrRecoverExecution +// must return a 409 and must NOT call disablePlan (no GetPurchasePlan call). +func TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409(t *testing.T) { + ctx := context.Background() + mockStore := new(MockConfigStore) + mockAuth := new(MockAuthService) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + + adminSession := &Session{ + UserID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + Email: "admin@example.com", + } + + execID := "abababab-abab-abab-abab-abababababab" + planID := "cdcdcdcd-cdcd-cdcd-cdcd-cdcdcdcdcdcd" + + conflictErr := fmt.Errorf("%w: execution %s cannot transition", config.ErrExecutionNotInExpectedStatus, execID) + + // The execution raced to "running" — not "cancelled". + runningExec := &config.PurchaseExecution{ + ExecutionID: execID, + PlanID: planID, + Status: "running", + } + + mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) + mockAuth.grantAdmin() + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled").Return(nil, conflictErr) + mockStore.On("GetExecutionByID", ctx, execID).Return(runningExec, nil) + // GetPurchasePlan must NOT be called — AssertExpectations verifies this. + + handler := &Handler{config: mockStore, auth: mockAuth} + + req := &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"Authorization": "Bearer admin-token"}, + } + result, err := handler.deletePlannedPurchase(ctx, req, execID) + require.Error(t, err, "racing-to-running execution must fail") + assert.Nil(t, result) + + ce, ok := IsClientError(err) + require.True(t, ok, "expected ClientError, got %T: %v", err, err) + assert.Equal(t, 409, ce.code, "status mismatch must return 409") + assert.Contains(t, ce.message, "cannot be cancelled", "error must name the action") + assert.Contains(t, ce.message, "running", "error must include actual status") +} + func TestHandler_pausePlannedPurchase_NilExecution(t *testing.T) { ctx := context.Background() mockStore := new(MockConfigStore) @@ -3317,3 +3367,143 @@ func TestHandler_authorizeSessionExecuteDirect_NilAuth(t *testing.T) { require.True(t, ok, "expected a clientError") assert.Equal(t, 500, ce.code) } + +// --- Issue #950: creator-scope ownership gate on scheduled-purchase mgmt --- +// +// These tests replicate the QA scenario in issue #950: a Standard user with +// update:purchases (and account access) must NOT be able to pause/resume/ +// cancel a scheduled purchase created by ANOTHER user. They drive the real +// handlers end-to-end (ValidateSession -> requirePermission -> account scope +// -> authorizeExecutionManagement -> transition) and FAIL against the pre-fix +// handler, which honoured the request because only update:purchases was +// checked. + +const ownExecID = "12121212-1212-1212-1212-121212121212" +const ownUserA = "aaaa1111-1111-1111-1111-111111111111" // creator of P1 +const ownUserB = "bbbb2222-2222-2222-2222-222222222222" // creator of P2 + +// buildManageHandler wires a non-admin "user-token" session for userID with +// account access (empty allowed_accounts -> all accessible) and the given +// update-any grant. The stored execution is created by creatorID. +func buildManageHandler(userID, creatorID string, hasUpdateAny bool) (*Handler, *MockConfigStore, *MockAuthService) { + mockAuth := new(MockAuthService) + mockAuth.On("ValidateSession", mock.Anything, "user-token").Return(&Session{UserID: userID}, nil) + mockAuth.On("HasPermissionAPI", mock.Anything, userID, "update", "purchases").Return(true, nil).Maybe() + mockAuth.On("HasPermissionAPI", mock.Anything, userID, "execute", "purchases").Return(true, nil).Maybe() + mockAuth.On("HasPermissionAPI", mock.Anything, userID, "delete", "purchases").Return(true, nil).Maybe() + mockAuth.On("HasPermissionAPI", mock.Anything, userID, "update-any", "purchases").Return(hasUpdateAny, nil).Maybe() + mockAuth.On("GetAllowedAccountsAPI", mock.Anything, userID).Return([]string{}, nil).Maybe() + + creator := creatorID + exec := &config.PurchaseExecution{ExecutionID: ownExecID, Status: "pending", CreatedByUserID: &creator} + mockConfig := new(MockConfigStore) + mockConfig.On("GetExecutionByID", mock.Anything, ownExecID).Return(exec, nil) + + return &Handler{config: mockConfig, auth: mockAuth}, mockConfig, mockAuth +} + +func manageReq() *events.LambdaFunctionURLRequest { + return &events.LambdaFunctionURLRequest{ + Headers: map[string]string{"authorization": "Bearer user-token"}, + } +} + +// TestHandler_pausePlannedPurchase_NonOwner_Rejected is the core #950 +// regression: user A pauses a scheduled purchase created by user B -> 403, +// and the status transition never runs. +func TestHandler_pausePlannedPurchase_NonOwner_Rejected(t *testing.T) { + handler, mockConfig, mockAuth := buildManageHandler(ownUserA, ownUserB, false) + + _, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) + assert.Contains(t, ce.message, "another user's scheduled purchase") + mockConfig.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockAuth.AssertExpectations(t) +} + +func TestHandler_resumePlannedPurchase_NonOwner_Rejected(t *testing.T) { + handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + + _, err := handler.resumePlannedPurchase(context.Background(), manageReq(), ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) + mockConfig.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestHandler_deletePlannedPurchase_NonOwner_Rejected(t *testing.T) { + handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + + _, err := handler.deletePlannedPurchase(context.Background(), manageReq(), ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) + mockConfig.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestHandler_runPlannedPurchase_NonOwner_Rejected(t *testing.T) { + handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, false) + + _, err := handler.runPlannedPurchase(context.Background(), manageReq(), ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) + mockConfig.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// TestHandler_pausePlannedPurchase_Owner_Allowed: user A manages their OWN P1. +func TestHandler_pausePlannedPurchase_Owner_Allowed(t *testing.T) { + handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserA, false) + mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused"). + Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) + + res, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) + require.NoError(t, err) + assert.Equal(t, "paused", res.Status) + mockConfig.AssertCalled(t, "TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused") +} + +// TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny: a privileged user with +// update-any:purchases manages P2 created by user B. +func TestHandler_pausePlannedPurchase_UpdateAny_AllowsAny(t *testing.T) { + handler, mockConfig, _ := buildManageHandler(ownUserA, ownUserB, true) + mockConfig.On("TransitionExecutionStatus", mock.Anything, ownExecID, []string{"pending", "running"}, "paused"). + Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "paused"}, nil) + + res, err := handler.pausePlannedPurchase(context.Background(), manageReq(), ownExecID) + require.NoError(t, err) + assert.Equal(t, "paused", res.Status) +} + +// TestHandler_authorizeExecutionManagement_NilAuth: fail-closed 500. +func TestHandler_authorizeExecutionManagement_NilAuth(t *testing.T) { + handler := &Handler{auth: nil} + err := handler.authorizeExecutionManagement(context.Background(), &Session{UserID: ownUserA}, ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 500, ce.code) +} + +// TestHandler_authorizeExecutionManagement_LegacyNullCreator: a legacy row +// with a NULL creator is unreachable for a non-update-any user. +func TestHandler_authorizeExecutionManagement_LegacyNullCreator(t *testing.T) { + mockAuth := new(MockAuthService) + mockAuth.On("HasPermissionAPI", mock.Anything, ownUserA, "update-any", "purchases").Return(false, nil) + mockConfig := new(MockConfigStore) + mockConfig.On("GetExecutionByID", mock.Anything, ownExecID). + Return(&config.PurchaseExecution{ExecutionID: ownExecID, Status: "pending", CreatedByUserID: nil}, nil) + handler := &Handler{config: mockConfig, auth: mockAuth} + + err := handler.authorizeExecutionManagement(context.Background(), &Session{UserID: ownUserA}, ownExecID) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok) + assert.Equal(t, 403, ce.code) +} diff --git a/internal/api/router_660_permission_flips_test.go b/internal/api/router_660_permission_flips_test.go index b435fd985..50f30e1b9 100644 --- a/internal/api/router_660_permission_flips_test.go +++ b/internal/api/router_660_permission_flips_test.go @@ -194,13 +194,18 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { const userID = "33333333-3333-3333-3333-333333333333" const execID = "cccccccc-cccc-cccc-cccc-cccccccccccc" - t.Run("user with update:purchases can pause a planned purchase", func(t *testing.T) { + t.Run("creator with update:purchases can pause their own planned purchase", func(t *testing.T) { + // Issue #950: a standard user manages only the scheduled purchases + // they created. update-any is false; the creator match authorises. mockAuth := authForUserWith(ctx, t, userID, "update", "purchases", true) mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) + creator := userID mockStore := new(MockConfigStore) - // requireExecutionAccess calls GetExecutionByID; stub a minimal row. + // requireExecutionAccess + authorizeExecutionManagement both call + // GetExecutionByID; stub a row created by this user. mockStore.On("GetExecutionByID", ctx, execID). - Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil) + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) // TransitionExecutionStatus is called next; stub it. mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "running"}, "paused"). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) @@ -210,6 +215,44 @@ func TestPausePlannedPurchase_PermissionGate(t *testing.T) { assertNotForbidden(t, err) }) + t.Run("non-creator with update:purchases is rejected with 403 (issue #950)", func(t *testing.T) { + // The user holds update:purchases (and account access) but did NOT + // create the execution and lacks update-any -> 403. This is the + // regression guard for the pre-fix authz hole. + mockAuth := authForUserWith(ctx, t, userID, "update", "purchases", true) + mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) + otherCreator := "99999999-9999-9999-9999-999999999999" + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID). + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil) + + h := &Handler{auth: mockAuth, config: mockStore} + _, err := h.pausePlannedPurchase(ctx, reqWithBearer("user-token"), execID) + assert403(t, err) + // The status transition must never run for a non-owner. + mockStore.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("update-any holder can pause another user's planned purchase (issue #950)", func(t *testing.T) { + // An operator role with update-any:purchases bypasses the creator + // check, mirroring cancel-any/approve-any on History. + mockAuth := authForUserWith(ctx, t, userID, "update", "purchases", true) + mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(true, nil) + mockStore := new(MockConfigStore) + // update-any short-circuits the ownership fetch in + // authorizeExecutionManagement; only requireExecutionAccess fetches. + mockStore.On("GetExecutionByID", ctx, execID). + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil) + mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "running"}, "paused"). + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "paused"}, nil) + + h := &Handler{auth: mockAuth, config: mockStore} + _, err := h.pausePlannedPurchase(ctx, reqWithBearer("user-token"), execID) + assertNotForbidden(t, err) + }) + t.Run("user without update:purchases is rejected with 403", func(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "update", "purchases", false) h := &Handler{auth: mockAuth, config: new(MockConfigStore)} @@ -239,12 +282,16 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { const userID = "44444444-4444-4444-4444-444444444444" const execID = "dddddddd-dddd-dddd-dddd-dddddddddddd" - t.Run("user with delete:purchases can delete a planned purchase", func(t *testing.T) { + t.Run("creator with delete:purchases can delete their own planned purchase", func(t *testing.T) { + // Issue #950: ownership gate also applies to delete; a creator with + // delete:purchases (no update-any) is authorised by the creator match. mockAuth := authForUserWith(ctx, t, userID, "delete", "purchases", true) mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) + creator := userID mockStore := new(MockConfigStore) mockStore.On("GetExecutionByID", ctx, execID). - Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil) + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &creator}, nil) mockStore.On("TransitionExecutionStatus", ctx, execID, []string{"pending", "paused"}, "cancelled"). Return(&config.PurchaseExecution{ExecutionID: execID, Status: "cancelled"}, nil) @@ -253,6 +300,21 @@ func TestDeletePlannedPurchase_PermissionGate(t *testing.T) { assertNotForbidden(t, err) }) + t.Run("non-creator with delete:purchases is rejected with 403 (issue #950)", func(t *testing.T) { + mockAuth := authForUserWith(ctx, t, userID, "delete", "purchases", true) + mockAuth.On("GetAllowedAccountsAPI", ctx, userID).Return([]string{}, nil) + mockAuth.On("HasPermissionAPI", ctx, userID, "update-any", "purchases").Return(false, nil) + otherCreator := "99999999-9999-9999-9999-999999999999" + mockStore := new(MockConfigStore) + mockStore.On("GetExecutionByID", ctx, execID). + Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil) + + h := &Handler{auth: mockAuth, config: mockStore} + _, err := h.deletePlannedPurchase(ctx, reqWithBearer("user-token"), execID) + assert403(t, err) + mockStore.AssertNotCalled(t, "TransitionExecutionStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + t.Run("user without delete:purchases is rejected with 403", func(t *testing.T) { mockAuth := authForUserWith(ctx, t, userID, "delete", "purchases", false) h := &Handler{auth: mockAuth, config: new(MockConfigStore)} diff --git a/internal/api/types.go b/internal/api/types.go index 40b979d7e..64aff5d71 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -624,6 +624,15 @@ type UpcomingPurchase struct { StepNumber int `json:"step_number"` TotalSteps int `json:"total_steps"` EstimatedSavings float64 `json:"estimated_savings"` + // CreatedByUserID propagates the underlying execution's + // created_by_user_id so the dashboard widget can apply the same + // creator-scope ownership gate the Plans page uses (issue #950). + // Without it the widget renders a "Cancel" button on every row + // while the backend now 403s for non-owners -- a UX hole that + // surfaces as a confusing toast on click. Mirrors the field on + // PlannedPurchase / PurchaseHistoryEntry. omitempty so legacy + // NULL-creator rows keep the JSON shape they had pre-fix. + CreatedByUserID *string `json:"created_by_user_id,omitempty"` } // PlannedPurchasesResponse holds the list of planned purchases @@ -649,6 +658,11 @@ type PlannedPurchase struct { Status string `json:"status"` StepNumber int `json:"step_number"` TotalSteps int `json:"total_steps"` + // CreatedByUserID is the UUID of the user who created the scheduled + // purchase, mirroring PurchaseHistoryRecord.CreatedByUserID. The + // frontend gates the row action buttons on creator-scope ownership + // (issue #950); omitted for legacy rows with a NULL creator. + CreatedByUserID *string `json:"created_by_user_id,omitempty"` } // PlanRequest represents the API request format for creating/updating plans diff --git a/internal/auth/types.go b/internal/auth/types.go index 4958182d7..398ddc93c 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -426,6 +426,28 @@ const ( // check. No default non-admin grant; add to a custom operator group. ActionExecuteOwn = "execute-own" ActionExecuteAny = "execute-any" + // ActionUpdateAny is the privileged escape that lets a holder manage + // (pause / resume / run / delete) a SCHEDULED purchase execution + // regardless of who created it (issue #950). It complements the base + // update:purchases verb every authenticated user already holds: that + // base verb authorises managing only your OWN scheduled purchases + // (created_by_user_id == session.UserID), while update-any drops the + // per-record ownership check. + // + // * RoleAdmin — implicit via {ActionAdmin, ResourceAll}; update-any is + // NOT in adminCarvedOuts, so admins manage every scheduled purchase. + // * RoleUser — NO default grant. A standard user manages only the + // scheduled purchases they created (base update:purchases + creator + // match). Legacy rows with NULL created_by_user_id are out of reach + // for non-admins (they hold neither update-any nor a creator match). + // * Custom operator groups — add update-any:purchases to let a role + // manage everyone's scheduled purchases without escalating to admin. + // + // There is no separate update-own verb: the existing update:purchases + // grant already plays that role, mirroring how cancel-own/approve-own + // gate History rows. The creator match is enforced in the handler + // (authorizeExecutionManagement), not in HasPermission. + ActionUpdateAny = "update-any" ) // Predefined resources