Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export {
updateRIExchangeConfig,
getRIExchangeHistory,
listTargetOfferings,
approveRIExchange
} from './riexchange';

// Re-export registrations functions and types
Expand Down
10 changes: 10 additions & 0 deletions frontend/src/api/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,13 @@ export async function getRIExchangeHistory(
const resp = await apiRequest<{ records: RIExchangeHistoryRecord[] }>(`/ri-exchange/history${qs}`);
return resp.records ?? [];
}

/**
* Approve a pending RI exchange via session auth (issue #300).
* Mirrors the purchase approvePurchase API method.
*/
export async function approveRIExchange(id: string): Promise<void> {
await apiRequest<unknown>(`/ri-exchange/approve/${encodeURIComponent(id)}`, {
method: 'POST',
});
}
4 changes: 4 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,10 @@ export interface RIExchangeHistoryRecord {
updated_at: string;
completed_at?: string;
expires_at?: string;
/** UUID of the session user who submitted this exchange (issue #300). */
created_by_user_id?: string;
/** Email of the session user who approved via the dashboard Approve button (issue #300). */
approved_by?: string;
}

// Inventory & Coverage types (issue #340 deferred sub-task — Active commitments)
Expand Down
61 changes: 60 additions & 1 deletion frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
import { openModal, closeModal } from './modal';
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
import { canAccess } from './permissions';
import { showToast } from './toast';
import { getCurrentUser } from './state';

// Module state
let currentRIs: ConvertibleRI[] = [];
Expand Down Expand Up @@ -998,6 +1000,25 @@ export async function saveAutomationSettings(): Promise<void> {
// Exchange History
// ──────────────────────────────────────────────

// canApproveRIExchangeRow returns true when the current session may approve
// the given pending RI exchange via the inline Approve button (issue #300).
// UX gate only — the backend authorizeSessionApproveRIExchange remains the
// security boundary; a false-positive here surfaces as a 403 toast on click.
//
// Heuristic mirrors canApprovePendingRow in history.ts:
// * status must be "pending";
// * admin -> always yes;
// * non-admin matching created_by_user_id -> yes (approve-own);
// * legacy rows without created_by_user_id -> no (email-link path only).
function canApproveRIExchangeRow(rec: RIExchangeHistoryRecord): boolean {
if ((rec.status || '').toLowerCase() !== 'pending') return false;
const user = getCurrentUser();
if (!user) return false;
if (canAccess('admin', '*') || canAccess('approve-any', 'purchases')) return true;
if (!rec.created_by_user_id) return false;
return canAccess('approve-own', 'purchases') && rec.created_by_user_id === user.id;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function loadExchangeHistory(): Promise<void> {
const container = document.getElementById('ri-exchange-history-list');
if (!container) return;
Expand Down Expand Up @@ -1047,6 +1068,9 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
const exchangeIdCell = rec.exchange_id
? '<span class="monospace">' + escapeHtml(rec.exchange_id) + '</span>'
: '&mdash;';
const approveBtn = canApproveRIExchangeRow(rec)
? '<button type="button" class="btn-link riexchange-approve-btn" data-approve-id="' + escapeHtml(rec.id) + '">Approve</button>'
: '';
return '<tr>'
+ '<td>' + escapeHtml(formatDateTime(rec.created_at)) + '</td>'
+ '<td>' + escapeHtml(String(rec.source_count)) + 'x ' + escapeHtml(rec.source_instance_type) + '</td>'
Expand All @@ -1055,12 +1079,13 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
+ '<td>$' + escapeHtml(rec.payment_due) + '</td>'
+ '<td><span class="' + getStatusBadgeClass(rec.status) + '">' + escapeHtml(rec.status) + '</span></td>'
+ '<td>' + exchangeIdCell + '</td>'
+ '<td>' + approveBtn + '</td>'
+ '</tr>';
}).join('');

const tableHTML = '<table>'
+ '<thead><tr>'
+ '<th>Date</th><th>Source Type</th><th>Target Type</th><th>Count</th><th>Payment</th><th>Status</th><th>Exchange ID</th>'
+ '<th>Date</th><th>Source Type</th><th>Target Type</th><th>Count</th><th>Payment</th><th>Status</th><th>Exchange ID</th><th>Actions</th>'
+ '</tr></thead>'
+ '<tbody>' + rowsHTML + '</tbody>'
+ '</table>';
Expand All @@ -1071,4 +1096,38 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
while (wrapper.firstChild) {
container.appendChild(wrapper.firstChild);
}

// Wire Approve button click handlers
container.querySelectorAll<HTMLButtonElement>('.riexchange-approve-btn[data-approve-id]').forEach(btn => {
btn.addEventListener('click', () => handleRIExchangeApproveClick(btn));
});
}

async function handleRIExchangeApproveClick(btn: HTMLButtonElement): Promise<void> {
const id = btn.dataset.approveId;
if (!id) return;

const confirmed = await confirmDialog({
title: 'Approve RI Exchange',
body: 'Approve this pending RI exchange? The exchange will execute immediately.',
confirmLabel: 'Approve',
});
if (!confirmed) return;

btn.disabled = true;
try {
await api.approveRIExchange(id);
showToast({ kind: 'success', message: 'RI exchange approved and executing.' });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showToast({ kind: 'error', message: 'Failed to approve exchange: ' + msg });
btn.disabled = false;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return;
}
try {
await loadExchangeHistory();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
showToast({ kind: 'error', message: 'Approved, but failed to refresh history: ' + msg });
}
}
3 changes: 3 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,9 @@ func (m *mockConfigStore) CompleteRIExchange(ctx context.Context, id string, exc
func (m *mockConfigStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error {
return nil
}
func (m *mockConfigStore) StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error {
return nil
}
func (m *mockConfigStore) GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error) {
return "0", nil
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/coverage_extras_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ func TestHandler_approveRIExchange_AlreadyProcessed(t *testing.T) {
Return(nil, nil)

h := &Handler{config: mockStore}
_, err := h.approveRIExchange(ctx, "11111111-1111-1111-1111-111111111111", "tok")
_, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, "11111111-1111-1111-1111-111111111111", "tok")
assert.Error(t, err)
assert.Contains(t, err.Error(), "already processed")
}
187 changes: 184 additions & 3 deletions internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -773,14 +773,58 @@ func (h *Handler) getRIExchangeHistory(ctx context.Context, req *events.LambdaFu
return &RIExchangeHistoryResponse{Records: records}, nil
}

// approveRIExchange handles approval of a pending RI exchange via token.
func (h *Handler) approveRIExchange(ctx context.Context, id, token string) (any, error) {
// approveRIExchange handles approval of a pending RI exchange.
//
// Three-mode dispatch mirroring approvePurchase (issue #286, issue #300):
//
// 1. Session present AND RBAC-authorized (admin / approve-any / approve-own
// match) -> session-authed approve, regardless of whether a token is also
// in the URL. Closes issue #300.
// 2. token != "" -> legacy email-link flow. validateExchangeApproval enforces
// the token-equality check; the permission-denied fall-through ensures a
// logged-in user without approve-* can still use an email link they hold.
// 3. token == "" AND no qualifying session -> 403 via
// approveRIExchangeViaSession's requireSession gate.
func (h *Handler) approveRIExchange(ctx context.Context, req *events.LambdaFunctionURLRequest, id, token string) (any, error) {
if session := h.tryGetSession(ctx, req); session != nil {
// Quick RBAC pre-check (no record fetch needed): does this session hold
// ANY approve right? If yes, hand off to approveRIExchangeViaSession
// which will re-check ownership with the actual record. If 403, fall
// through to the token branch so email-link holders can still approve.
switch err := h.sessionHasApproveRight(ctx, session); {
case err == nil:
result, sessErr := h.approveRIExchangeViaSession(ctx, req, id, session)
if sessErr == nil {
return result, nil
}
// Record-level RBAC denied (e.g. approve-own user is not the creator).
// If a token is present, preserve legacy token flow; otherwise surface the error.
if !(token != "" && isPermissionDenied(sessErr)) {
return nil, sessErr
}
case isPermissionDenied(err):
// Logged-in user without approve-* may still hold a valid email token.
default:
return nil, err
}
}

if token != "" {
return h.approveRIExchangeViaToken(ctx, id, token)
}

return h.approveRIExchangeViaSession(ctx, req, id, nil)
}

// approveRIExchangeViaToken is the legacy email-link branch of approveRIExchange.
// It validates the approval token, transitions the exchange to processing, and
// executes it. Extracted to keep approveRIExchange within cyclomatic-complexity limits.
func (h *Handler) approveRIExchangeViaToken(ctx context.Context, id, token string) (any, error) {
record, err := h.validateExchangeApproval(ctx, id, token)
if err != nil {
return nil, err
}

// Atomic transition: pending -> processing (checks expiry in WHERE clause)
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
if err != nil {
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
Expand All @@ -792,6 +836,143 @@ func (h *Handler) approveRIExchange(ctx context.Context, id, token string) (any,
return h.executeApprovedExchange(ctx, id, record)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// approveRIExchangeViaSession is the session-authed branch of approveRIExchange
// (issue #300). Enforces the approve-any/approve-own RBAC matrix, then atomically
// transitions pending -> processing and executes the exchange. Stamps
// session.Email onto the approved_by column as an audit trail.
//
// The session parameter may be non-nil (already validated by the caller) or nil
// (requireSession will validate it and return 401 if absent).
func (h *Handler) approveRIExchangeViaSession(ctx context.Context, req *events.LambdaFunctionURLRequest, id string, session *Session) (any, error) {
var err error
if session == nil {
session, err = h.requireSession(ctx, req)
if err != nil {
return nil, err
}
}

record, err := h.fetchAndAuthorizeRIExchange(ctx, session, id)
if err != nil {
return nil, err
}

transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
if err != nil {
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
}
if transitioned == nil {
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run")
}

result, execErr := h.executeApprovedExchange(ctx, id, record)

// Stamp approver attribution (best-effort: the exchange itself already
// executed, so a stamp failure is logged but not surfaced to the caller).
if execErr == nil {
if stampErr := h.config.StampRIExchangeApprovedBy(ctx, id, session.Email); stampErr != nil {
logging.Errorf("failed to stamp approved_by on exchange %s: %v", id, stampErr)
}
}

return result, execErr
}

// fetchAndAuthorizeRIExchange looks up the pending exchange record by id, checks
// that it is in "pending" state, and then verifies that session is authorised to
// approve it. Extracted from approveRIExchangeViaSession to keep that function
// under the cyclomatic-complexity limit.
func (h *Handler) fetchAndAuthorizeRIExchange(ctx context.Context, session *Session, id string) (*config.RIExchangeRecord, error) {
if err := validateUUID(id); err != nil {
return nil, err
}

record, err := h.config.GetRIExchangeRecord(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to look up exchange record: %w", err)
}
if record == nil {
return nil, NewClientError(404, "exchange record not found")
}

if record.Status != "pending" {
return nil, NewClientError(409, fmt.Sprintf("exchange %s cannot be approved (status=%s)", id, record.Status))
}

if err := h.authorizeSessionApproveRIExchange(ctx, session, record); err != nil {
return nil, err
}

return record, nil
}

// sessionHasApproveRight returns nil when the session holds ANY approve right
// on purchases (admin / approve-any / approve-own) without checking ownership.
// Used by the three-mode dispatch in approveRIExchange to decide whether to route
// to approveRIExchangeViaSession before fetching the record.
func (h *Handler) sessionHasApproveRight(ctx context.Context, session *Session) error {
if session.Role == "admin" {
return nil
}
if h.auth == nil {
return NewClientError(500, "authentication service not configured")
}
hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveAny, auth.ResourcePurchases)
if err != nil {
return fmt.Errorf("permission check failed: %w", err)
}
if hasAny {
return nil
}
hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases)
if err != nil {
return fmt.Errorf("permission check failed: %w", err)
}
if hasOwn {
return nil
}
return NewClientError(403, "permission denied: requires approve-any or approve-own on purchases")
}

// authorizeSessionApproveRIExchange returns nil when the session is permitted to
// approve the given RI exchange record under the approve-any / approve-own RBAC rules
// (issue #300). Mirrors authorizeSessionApprove from handler_purchases.go.
//
// The RI exchange shares ResourcePurchases because approval is conceptually
// "approving a purchase action on a different resource type" per the issue spec,
// which prefers reusing the existing verbs to keep the matrix small.
func (h *Handler) authorizeSessionApproveRIExchange(ctx context.Context, session *Session, record *config.RIExchangeRecord) error {
if session.Role == "admin" {
return nil
}
if h.auth == nil {
return NewClientError(500, "authentication service not configured")
}

hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveAny, auth.ResourcePurchases)
if err != nil {
return fmt.Errorf("permission check failed: %w", err)
}
if hasAny {
return nil
}

hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases)
if err != nil {
return fmt.Errorf("permission check failed: %w", err)
}
if !hasOwn {
return NewClientError(403, "permission denied: requires approve-any or approve-own on purchases")
}

// approve-own: only allow if the session user created this exchange.
if record.CreatedByUserID == nil || *record.CreatedByUserID != session.UserID {
return NewClientError(403, "permission denied: cannot approve another user's pending exchange")
}

return nil
}

// validateExchangeApproval validates ID, token, and record state for an exchange approval.
func (h *Handler) validateExchangeApproval(ctx context.Context, id, token string) (*config.RIExchangeRecord, error) {
if err := validateUUID(id); err != nil {
Expand Down
Loading
Loading