feat(ri-exchange): symmetric session-auth approve path (closes #300) - #590
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThis PR extends RI exchange approval to support session-based RBAC authorization alongside the existing email-token legacy path. It adds a dashboard Approve button, refactors the approval dispatcher with three-mode routing, implements approve-any/approve-own authorization rules, persists creator and approver attribution, and provides comprehensive test coverage for both auth flows. ChangesRI Exchange Dual-Auth Approval Feature
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
internal/api/router.go (1)
704-709:⚠️ Potential issue | 🟠 Major | ⚡ Quick winUse
resolveApprovalToken(req)for RI exchange approval token extraction.Reading the token only from
req.QueryStringParameters["token"]keeps sensitive approval tokens in URL/query logs and diverges from the hardened purchase flow.💡 Proposed fix
func (r *Router) approveRIExchangeHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { if err := r.h.checkRateLimit(ctx, req, "approve_cancel_public"); err != nil { return nil, err } - return r.h.approveRIExchange(ctx, req, params["id"], req.QueryStringParameters["token"]) + token := resolveApprovalToken(req) + return r.h.approveRIExchange(ctx, req, params["id"], token) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/api/router.go` around lines 704 - 709, In approveRIExchangeHandler replace direct query access of the token (req.QueryStringParameters["token"]) with the secure extractor resolveApprovalToken(req) so the handler calls r.h.approveRIExchange(ctx, req, params["id"], resolveApprovalToken(req)); this ensures token extraction follows the hardened purchase flow and avoids exposing sensitive tokens in URL/query logs — update the callsite in approveRIExchangeHandler to use resolveApprovalToken instead of reading QueryStringParameters directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/src/riexchange.ts`:
- Around line 1009-1016: The catch currently covers both approveRIExchange and
loadExchangeHistory so a refresh failure is reported as an approval failure;
split the flows: call await api.approveRIExchange(id) in its own try block, show
the success toast and only then call loadExchangeHistory in a separate try/catch
that handles refresh errors (e.g., show a different error toast like "Failed to
refresh exchange history" but do not claim approval failed). Ensure btn.disabled
is correctly managed (disable before approval, re-enable in both catch blocks as
needed) and reference the functions approveRIExchange, loadExchangeHistory and
the btn.disabled flag when making these changes.
- Around line 904-911: The canApproveRIExchangeRow predicate only allows admins
or the record owner to approve, missing users granted the approve-any:purchases
permission; update canApproveRIExchangeRow (and use getCurrentUser()) to also
return true when the current user has the approve-any:purchases permission
(e.g., check user.permissions.includes('approve-any:purchases') or call the
existing permission helper if present) while keeping the existing pending-status
and owner/admin checks and the created_by_user_id equality check.
In `@internal/api/handler_ri_exchange_test.go`:
- Line 353: The test currently ignores the return values from
h.approveRIExchange(ctx, req, id, ""), which can hide failures; update the three
call sites (the one shown and the other occurrences around the same test) to
capture the returned error (and result if applicable) and assert it is nil (or
assert the expected error) immediately after the call so the test fails on
regressions rather than only on side effects; reference the call to
h.approveRIExchange and the local vars ctx, req, id when making the assertion.
In `@internal/api/handler_ri_exchange.go`:
- Around line 591-625: The approveRIExchange function currently returns
immediately when sessionHasApproveRight(ctx, session) == nil by calling
approveRIExchangeViaSession, which prevents trying the token path if
approveRIExchangeViaSession returns a permission-denied error for a non-creator;
change the flow so you still call approveRIExchangeViaSession when
sessionHasApproveRight == nil but inspect its returned error: if
approveRIExchangeViaSession returns a permission-denied error (use
isPermissionDenied) and a non-empty token was provided, do NOT return and
instead continue to the token branch (calling validateExchangeApproval /
TransitionRIExchangeStatus / executeApprovedExchange); for any other error or
success, return the result as before. Use the existing symbols
approveRIExchange, sessionHasApproveRight, approveRIExchangeViaSession,
isPermissionDenied, validateExchangeApproval, TransitionRIExchangeStatus, and
executeApprovedExchange to locate and implement this change.
---
Outside diff comments:
In `@internal/api/router.go`:
- Around line 704-709: In approveRIExchangeHandler replace direct query access
of the token (req.QueryStringParameters["token"]) with the secure extractor
resolveApprovalToken(req) so the handler calls r.h.approveRIExchange(ctx, req,
params["id"], resolveApprovalToken(req)); this ensures token extraction follows
the hardened purchase flow and avoids exposing sensitive tokens in URL/query
logs — update the callsite in approveRIExchangeHandler to use
resolveApprovalToken instead of reading QueryStringParameters directly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: eb2fbf94-29df-4449-b804-819cd2937ba2
📒 Files selected for processing (20)
frontend/src/api/index.tsfrontend/src/api/riexchange.tsfrontend/src/api/types.tsfrontend/src/riexchange.tsinternal/analytics/collector_test.gointernal/api/coverage_extras_test.gointernal/api/handler_ri_exchange.gointernal/api/handler_ri_exchange_test.gointernal/api/mocks_test.gointernal/api/router.gointernal/config/interfaces.gointernal/config/store_postgres.gointernal/config/store_postgres_nil_db_test.gointernal/config/store_postgres_pgxmock_test.gointernal/config/types.gointernal/database/postgres/migrations/000053_ri_exchange_approver_creator.down.sqlinternal/database/postgres/migrations/000053_ri_exchange_approver_creator.up.sqlinternal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/test_helpers_test.go
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.
…903828) Finding 1 (Major): canApproveRIExchangeRow now uses canAccess() for both admin/* and approve-any:purchases holders; approve-own:purchases replaces the bare user-id equality check. Non-admin approve-any holders can now see and click the Approve button. Finding 2 (Minor): split approveRIExchange's single try/catch into two blocks so a refresh failure no longer triggers the "Failed to approve" error toast. Approval and history reload are independent failures. Finding 3 (Minor): three test call sites that silently discarded the returned err now capture it and assert require.NoError, ensuring regressions surface instead of passing on side-effect checks alone. Finding 4 (Major): when sessionHasApproveRight passes but approveRIExchangeViaSession returns permission-denied (approve-own user not the creator), fall through to the token path if a token is present instead of returning the 403. Legacy email-link approval now works for this RBAC shape.
bd20098 to
681efb5
Compare
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
After the rebase onto current feat/multicloud-web-frontend, the PR's new migration collided with the base branch's 000053_executions_ account_fk_restrict. Renumber via `git mv` to the next free slot (000054). Project memory `project_migration_number_collisions.md` covers this exact failure mode (pre-commit hook fails locally and in CI when two PRs both add migrations numbered the same after one of them lands first). - 000053_ri_exchange_approver_creator.up.sql -> 000054_* - 000053_ri_exchange_approver_creator.down.sql -> 000054_* - Updated the "Migration 000053" / "Revert migration 000053" comment headers inside each file to match the new number. Verified 000054 is free on origin/feat and no open PR introduces a clashing migration.
…158) Replace the user.role === 'admin' check in canCancelPendingRow with the canAccess predicate pattern from PR #590: canAccess('admin', '*') || canAccess('cancel-any', 'purchases') || (canAccess('cancel-own', 'purchases') && row.created_by_user_id === user.id). Removes the deferred-work caveat block. Adds history-cancel-permissions.test.ts covering the full 6-case permission matrix.
…158) (#835) Replace the user.role === 'admin' check in canCancelPendingRow with the canAccess predicate pattern from PR #590: canAccess('admin', '*') || canAccess('cancel-any', 'purchases') || (canAccess('cancel-own', 'purchases') && row.created_by_user_id === user.id). Removes the deferred-work caveat block. Adds history-cancel-permissions.test.ts covering the full 6-case permission matrix.
Mirrors the in-dashboard approve pattern from #286 onto the RI Exchange flow.
Closes #300.
Changes
Summary by CodeRabbit
New Features
Database