Skip to content

Commit bd20098

Browse files
committed
feat(ri-exchange): add symmetric session-auth approve path (closes #300)
Mirror the three-mode dispatch introduced for purchases (#286): 1. Session with approve-any/approve-own on ResourcePurchases -> RBAC approve, stamping approved_by for audit trail. 2. Legacy email token -> unchanged behaviour. 3. No session and no token -> 401 via requireSession. Migration 000053 adds nullable approved_by and created_by_user_id to ri_exchange_history. The Approve button appears in the RI Exchange history panel for rows the current user is authorised to approve.
1 parent bc8831b commit bd20098

20 files changed

Lines changed: 524 additions & 39 deletions

‎frontend/src/api/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ export {
181181
executeExchange,
182182
getRIExchangeConfig,
183183
updateRIExchangeConfig,
184-
getRIExchangeHistory
184+
getRIExchangeHistory,
185+
approveRIExchange
185186
} from './riexchange';
186187

187188
// Re-export registrations functions and types

‎frontend/src/api/riexchange.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,13 @@ export async function getRIExchangeHistory(
9494
const resp = await apiRequest<{ records: RIExchangeHistoryRecord[] }>(`/ri-exchange/history${qs}`);
9595
return resp.records ?? [];
9696
}
97+
98+
/**
99+
* Approve a pending RI exchange via session auth (issue #300).
100+
* Mirrors the purchase approvePurchase API method.
101+
*/
102+
export async function approveRIExchange(id: string): Promise<void> {
103+
await apiRequest<unknown>(`/ri-exchange/approve/${encodeURIComponent(id)}`, {
104+
method: 'POST',
105+
});
106+
}

‎frontend/src/api/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,10 @@ export interface RIExchangeHistoryRecord {
592592
updated_at: string;
593593
completed_at?: string;
594594
expires_at?: string;
595+
/** UUID of the session user who submitted this exchange (issue #300). */
596+
created_by_user_id?: string;
597+
/** Email of the session user who approved via the dashboard Approve button (issue #300). */
598+
approved_by?: string;
595599
}
596600

597601
// Inventory & Coverage types (issue #340 deferred sub-task — Active commitments)

‎frontend/src/riexchange.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import type {
1919
import { openModal, closeModal } from './modal';
2020
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
2121
import { canAccess } from './permissions';
22+
import { showToast } from './toast';
23+
import { getCurrentUser } from './state';
2224

2325
// Module state
2426
let currentRIs: ConvertibleRI[] = [];
@@ -889,6 +891,25 @@ export async function saveAutomationSettings(): Promise<void> {
889891
// Exchange History
890892
// ──────────────────────────────────────────────
891893

894+
// canApproveRIExchangeRow returns true when the current session may approve
895+
// the given pending RI exchange via the inline Approve button (issue #300).
896+
// UX gate only — the backend authorizeSessionApproveRIExchange remains the
897+
// security boundary; a false-positive here surfaces as a 403 toast on click.
898+
//
899+
// Heuristic mirrors canApprovePendingRow in history.ts:
900+
// * status must be "pending";
901+
// * admin -> always yes;
902+
// * non-admin matching created_by_user_id -> yes (approve-own);
903+
// * legacy rows without created_by_user_id -> no (email-link path only).
904+
function canApproveRIExchangeRow(rec: RIExchangeHistoryRecord): boolean {
905+
if ((rec.status || '').toLowerCase() !== 'pending') return false;
906+
const user = getCurrentUser();
907+
if (!user) return false;
908+
if (user.role === 'admin') return true;
909+
if (!rec.created_by_user_id) return false;
910+
return rec.created_by_user_id === user.id;
911+
}
912+
892913
async function loadExchangeHistory(): Promise<void> {
893914
const container = document.getElementById('ri-exchange-history-list');
894915
if (!container) return;
@@ -938,6 +959,9 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
938959
const exchangeIdCell = rec.exchange_id
939960
? '<span class="monospace">' + escapeHtml(rec.exchange_id) + '</span>'
940961
: '&mdash;';
962+
const approveBtn = canApproveRIExchangeRow(rec)
963+
? '<button type="button" class="btn-link riexchange-approve-btn" data-approve-id="' + escapeHtml(rec.id) + '">Approve</button>'
964+
: '';
941965
return '<tr>'
942966
+ '<td>' + escapeHtml(formatDateTime(rec.created_at)) + '</td>'
943967
+ '<td>' + escapeHtml(String(rec.source_count)) + 'x ' + escapeHtml(rec.source_instance_type) + '</td>'
@@ -946,12 +970,13 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
946970
+ '<td>$' + escapeHtml(rec.payment_due) + '</td>'
947971
+ '<td><span class="' + getStatusBadgeClass(rec.status) + '">' + escapeHtml(rec.status) + '</span></td>'
948972
+ '<td>' + exchangeIdCell + '</td>'
973+
+ '<td>' + approveBtn + '</td>'
949974
+ '</tr>';
950975
}).join('');
951976

952977
const tableHTML = '<table>'
953978
+ '<thead><tr>'
954-
+ '<th>Date</th><th>Source Type</th><th>Target Type</th><th>Count</th><th>Payment</th><th>Status</th><th>Exchange ID</th>'
979+
+ '<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>'
955980
+ '</tr></thead>'
956981
+ '<tbody>' + rowsHTML + '</tbody>'
957982
+ '</table>';
@@ -962,4 +987,32 @@ function renderExchangeHistory(container: HTMLElement, records: RIExchangeHistor
962987
while (wrapper.firstChild) {
963988
container.appendChild(wrapper.firstChild);
964989
}
990+
991+
// Wire Approve button click handlers
992+
container.querySelectorAll<HTMLButtonElement>('.riexchange-approve-btn[data-approve-id]').forEach(btn => {
993+
btn.addEventListener('click', () => handleRIExchangeApproveClick(btn));
994+
});
995+
}
996+
997+
async function handleRIExchangeApproveClick(btn: HTMLButtonElement): Promise<void> {
998+
const id = btn.dataset.approveId;
999+
if (!id) return;
1000+
1001+
const confirmed = await confirmDialog({
1002+
title: 'Approve RI Exchange',
1003+
body: 'Approve this pending RI exchange? The exchange will execute immediately.',
1004+
confirmLabel: 'Approve',
1005+
});
1006+
if (!confirmed) return;
1007+
1008+
btn.disabled = true;
1009+
try {
1010+
await api.approveRIExchange(id);
1011+
showToast({ kind: 'success', message: 'RI exchange approved and executing.' });
1012+
await loadExchangeHistory();
1013+
} catch (err) {
1014+
const msg = err instanceof Error ? err.message : String(err);
1015+
showToast({ kind: 'error', message: 'Failed to approve exchange: ' + msg });
1016+
btn.disabled = false;
1017+
}
9651018
}

‎internal/analytics/collector_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ func (m *mockConfigStore) CompleteRIExchange(ctx context.Context, id string, exc
221221
func (m *mockConfigStore) FailRIExchange(ctx context.Context, id string, errorMsg string) error {
222222
return nil
223223
}
224+
func (m *mockConfigStore) StampRIExchangeApprovedBy(ctx context.Context, id string, approverEmail string) error {
225+
return nil
226+
}
224227
func (m *mockConfigStore) GetRIExchangeDailySpend(ctx context.Context, date time.Time) (string, error) {
225228
return "0", nil
226229
}

‎internal/api/coverage_extras_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ func TestHandler_approveRIExchange_AlreadyProcessed(t *testing.T) {
366366
Return(nil, nil)
367367

368368
h := &Handler{config: mockStore}
369-
_, err := h.approveRIExchange(ctx, "11111111-1111-1111-1111-111111111111", "tok")
369+
_, err := h.approveRIExchange(ctx, &events.LambdaFunctionURLRequest{}, "11111111-1111-1111-1111-111111111111", "tok")
370370
assert.Error(t, err)
371371
assert.Contains(t, err.Error(), "already processed")
372372
}

‎internal/api/handler_ri_exchange.go‎

Lines changed: 171 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -576,14 +576,75 @@ func (h *Handler) getRIExchangeHistory(ctx context.Context, req *events.LambdaFu
576576
return &RIExchangeHistoryResponse{Records: records}, nil
577577
}
578578

579-
// approveRIExchange handles approval of a pending RI exchange via token.
580-
func (h *Handler) approveRIExchange(ctx context.Context, id, token string) (any, error) {
581-
record, err := h.validateExchangeApproval(ctx, id, token)
579+
// approveRIExchange handles approval of a pending RI exchange.
580+
//
581+
// Three-mode dispatch mirroring approvePurchase (issue #286, issue #300):
582+
//
583+
// 1. Session present AND RBAC-authorized (admin / approve-any / approve-own
584+
// match) -> session-authed approve, regardless of whether a token is also
585+
// in the URL. Closes issue #300.
586+
// 2. token != "" -> legacy email-link flow. validateExchangeApproval enforces
587+
// the token-equality check; the permission-denied fall-through ensures a
588+
// logged-in user without approve-* can still use an email link they hold.
589+
// 3. token == "" AND no qualifying session -> 403 via
590+
// approveRIExchangeViaSession's requireSession gate.
591+
func (h *Handler) approveRIExchange(ctx context.Context, req *events.LambdaFunctionURLRequest, id, token string) (any, error) {
592+
if session := h.tryGetSession(ctx, req); session != nil {
593+
// Quick RBAC pre-check (no record fetch needed): does this session hold
594+
// ANY approve right? If yes, hand off to approveRIExchangeViaSession
595+
// which will re-check ownership with the actual record. If 403, fall
596+
// through to the token branch so email-link holders can still approve.
597+
switch err := h.sessionHasApproveRight(ctx, session); {
598+
case err == nil:
599+
return h.approveRIExchangeViaSession(ctx, req, id, session)
600+
case isPermissionDenied(err):
601+
// Logged-in user without approve-* may still hold a valid email token.
602+
default:
603+
return nil, err
604+
}
605+
}
606+
607+
if token != "" {
608+
record, err := h.validateExchangeApproval(ctx, id, token)
609+
if err != nil {
610+
return nil, err
611+
}
612+
613+
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
614+
if err != nil {
615+
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
616+
}
617+
if transitioned == nil {
618+
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run")
619+
}
620+
621+
return h.executeApprovedExchange(ctx, id, record)
622+
}
623+
624+
return h.approveRIExchangeViaSession(ctx, req, id, nil)
625+
}
626+
627+
// approveRIExchangeViaSession is the session-authed branch of approveRIExchange
628+
// (issue #300). Enforces the approve-any/approve-own RBAC matrix, then atomically
629+
// transitions pending -> processing and executes the exchange. Stamps
630+
// session.Email onto the approved_by column as an audit trail.
631+
//
632+
// The session parameter may be non-nil (already validated by the caller) or nil
633+
// (requireSession will validate it and return 401 if absent).
634+
func (h *Handler) approveRIExchangeViaSession(ctx context.Context, req *events.LambdaFunctionURLRequest, id string, session *Session) (any, error) {
635+
var err error
636+
if session == nil {
637+
session, err = h.requireSession(ctx, req)
638+
if err != nil {
639+
return nil, err
640+
}
641+
}
642+
643+
record, err := h.fetchAndAuthorizeRIExchange(ctx, session, id)
582644
if err != nil {
583645
return nil, err
584646
}
585647

586-
// Atomic transition: pending -> processing (checks expiry in WHERE clause)
587648
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
588649
if err != nil {
589650
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
@@ -592,7 +653,112 @@ func (h *Handler) approveRIExchange(ctx context.Context, id, token string) (any,
592653
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run")
593654
}
594655

595-
return h.executeApprovedExchange(ctx, id, record)
656+
result, execErr := h.executeApprovedExchange(ctx, id, record)
657+
658+
// Stamp approver attribution (best-effort: the exchange itself already
659+
// executed, so a stamp failure is logged but not surfaced to the caller).
660+
if execErr == nil {
661+
if stampErr := h.config.StampRIExchangeApprovedBy(ctx, id, session.Email); stampErr != nil {
662+
logging.Errorf("failed to stamp approved_by on exchange %s: %v", id, stampErr)
663+
}
664+
}
665+
666+
return result, execErr
667+
}
668+
669+
// fetchAndAuthorizeRIExchange looks up the pending exchange record by id, checks
670+
// that it is in "pending" state, and then verifies that session is authorised to
671+
// approve it. Extracted from approveRIExchangeViaSession to keep that function
672+
// under the cyclomatic-complexity limit.
673+
func (h *Handler) fetchAndAuthorizeRIExchange(ctx context.Context, session *Session, id string) (*config.RIExchangeRecord, error) {
674+
if err := validateUUID(id); err != nil {
675+
return nil, err
676+
}
677+
678+
record, err := h.config.GetRIExchangeRecord(ctx, id)
679+
if err != nil {
680+
return nil, fmt.Errorf("failed to look up exchange record: %w", err)
681+
}
682+
if record == nil {
683+
return nil, NewClientError(404, "exchange record not found")
684+
}
685+
686+
if record.Status != "pending" {
687+
return nil, NewClientError(409, fmt.Sprintf("exchange %s cannot be approved (status=%s)", id, record.Status))
688+
}
689+
690+
if err := h.authorizeSessionApproveRIExchange(ctx, session, record); err != nil {
691+
return nil, err
692+
}
693+
694+
return record, nil
695+
}
696+
697+
// sessionHasApproveRight returns nil when the session holds ANY approve right
698+
// on purchases (admin / approve-any / approve-own) without checking ownership.
699+
// Used by the three-mode dispatch in approveRIExchange to decide whether to route
700+
// to approveRIExchangeViaSession before fetching the record.
701+
func (h *Handler) sessionHasApproveRight(ctx context.Context, session *Session) error {
702+
if session.Role == "admin" {
703+
return nil
704+
}
705+
if h.auth == nil {
706+
return NewClientError(500, "authentication service not configured")
707+
}
708+
hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveAny, auth.ResourcePurchases)
709+
if err != nil {
710+
return fmt.Errorf("permission check failed: %w", err)
711+
}
712+
if hasAny {
713+
return nil
714+
}
715+
hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases)
716+
if err != nil {
717+
return fmt.Errorf("permission check failed: %w", err)
718+
}
719+
if hasOwn {
720+
return nil
721+
}
722+
return NewClientError(403, "permission denied: requires approve-any or approve-own on purchases")
723+
}
724+
725+
// authorizeSessionApproveRIExchange returns nil when the session is permitted to
726+
// approve the given RI exchange record under the approve-any / approve-own RBAC rules
727+
// (issue #300). Mirrors authorizeSessionApprove from handler_purchases.go.
728+
//
729+
// The RI exchange shares ResourcePurchases because approval is conceptually
730+
// "approving a purchase action on a different resource type" per the issue spec,
731+
// which prefers reusing the existing verbs to keep the matrix small.
732+
func (h *Handler) authorizeSessionApproveRIExchange(ctx context.Context, session *Session, record *config.RIExchangeRecord) error {
733+
if session.Role == "admin" {
734+
return nil
735+
}
736+
if h.auth == nil {
737+
return NewClientError(500, "authentication service not configured")
738+
}
739+
740+
hasAny, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveAny, auth.ResourcePurchases)
741+
if err != nil {
742+
return fmt.Errorf("permission check failed: %w", err)
743+
}
744+
if hasAny {
745+
return nil
746+
}
747+
748+
hasOwn, err := h.auth.HasPermissionAPI(ctx, session.UserID, auth.ActionApproveOwn, auth.ResourcePurchases)
749+
if err != nil {
750+
return fmt.Errorf("permission check failed: %w", err)
751+
}
752+
if !hasOwn {
753+
return NewClientError(403, "permission denied: requires approve-any or approve-own on purchases")
754+
}
755+
756+
// approve-own: only allow if the session user created this exchange.
757+
if record.CreatedByUserID == nil || *record.CreatedByUserID != session.UserID {
758+
return NewClientError(403, "permission denied: cannot approve another user's pending exchange")
759+
}
760+
761+
return nil
596762
}
597763

598764
// validateExchangeApproval validates ID, token, and record state for an exchange approval.

0 commit comments

Comments
 (0)