Skip to content

fix(api): scope holdings endpoints to accounts accessible by the token owner#2468

Open
cleanjunc wants to merge 1 commit into
we-promise:mainfrom
cleanjunc:fix/holdings-api-account-scope
Open

fix(api): scope holdings endpoints to accounts accessible by the token owner#2468
cleanjunc wants to merge 1 commit into
we-promise:mainfrom
cleanjunc:fix/holdings-api-account-scope

Conversation

@cleanjunc

@cleanjunc cleanjunc commented Jun 23, 2026

Copy link
Copy Markdown

Summary

The /api/v1/holdings list and show endpoints scoped results to the whole family instead of the accounts the API token owner can actually access. A read scoped key could therefore read holdings (account name, quantity, price, market value) for accounts owned by other family members that were never shared with the key owner, and the account_id / account_ids filters made targeted enumeration easy.

This brings the holdings API in line with the balances API and the web holdings controller, both of which already scope through accounts.accessible_by.

Fixes #2467

What changed

  • Added a single accessible_holdings scope that restricts holdings to accounts the token owner owns or has been shared, while keeping the existing draft / active status filter.
  • Routed both index and set_holding through that scope, so the list, the show action, and the account_id / account_ids filters all respect access boundaries.
# Holdings in accounts the user can access (owned or shared), not the whole family.
def accessible_holdings
  account_ids = current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id)
  Holding.joins(:account).where(accounts: { status: %w[draft active], id: account_ids })
end

Why

Holdings expose financial position data, so the leak is a real privacy gap between members of the same family. Scoping through accessible_by matches the established pattern and keeps the access rules consistent across the codebase.

Testing

Added test/controllers/api/v1/holdings_controller_test.rb, covering list scoping, the account_ids enumeration vector, show for an accessible holding, a not found response for an inaccessible holding, and the authentication requirement.

5 runs, 12 assertions, 0 failures, 0 errors, 0 skips

Summary by CodeRabbit

  • Bug Fixes

    • Holdings endpoint now properly restricts visibility to authorized holdings only
    • Fixed account filtering to prevent unauthorized access attempts
  • Tests

    • Added comprehensive test coverage for holdings API access control and authentication

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

HoldingsController#accessible_holdings is rewritten to compute permitted account IDs via accounts.accessible_by(current_resource_owner) and scope Holding to those IDs. The index action and set_holding lookup now use this helper instead of the full family scope. A new integration test suite verifies the scoping, filter bypass prevention, and authentication.

Changes

Holdings API access control scoping

Layer / File(s) Summary
accessible_holdings scoping and index/show wiring
app/controllers/api/v1/holdings_controller.rb
accessible_holdings rewritten to derive permitted account_ids from family.accounts.accessible_by(current_resource_owner), then scope Holding by joining :account and filtering on status and those IDs. index now seeds holdings_query from accessible_holdings; set_holding also uses accessible_holdings.find.
Integration tests for scoped holdings access
test/controllers/api/v1/holdings_controller_test.rb
New test class with setup for two users/accounts/holdings; five tests covering index scoping, account_ids filter bypass prevention, accessible show success, inaccessible show returning not_found, and unauthenticated unauthorized.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Poem

🐇 Hop, hop! The gates are shut tight,
No peeking at holdings beyond your right.
accessible_by guards each account's door,
Private positions stay private once more.
The rabbit checks keys before sharing the stash—
Only your own holdings make it through the hash! 🔑

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main fix: scoping holdings endpoints to accessible accounts, matching the core security vulnerability being addressed.
Linked Issues check ✅ Passed The PR directly addresses issue #2467 by implementing accessible_holdings scope in both index and show actions, preventing unauthorized access to holdings from accounts not shared with the token owner.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the vulnerability: the controller modifications implement access control, and new test coverage validates the fix without introducing unrelated changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d35b6e074

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

def index
family = current_resource_owner.family
holdings_query = family.holdings.joins(:account).where(accounts: { status: [ "draft", "active" ] })
holdings_query = accessible_holdings

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update OpenAPI docs for holdings scoping

This changes the /api/v1/holdings list/show behavior to scope by accessible accounts, but the corresponding rswag spec and generated docs/api/openapi.yaml were not updated. The repository's AGENTS.md OpenAPI Documentation section marks spec/requests/api/v1/{resource}_spec.rb and regenerated OpenAPI docs as mandatory for changes under app/controllers/api/v1/, so the public API contract will not document the new accessible-account behavior for account_id/account_ids filtering or show responses until those docs are refreshed.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/controllers/api/v1/holdings_controller.rb (1)

39-41: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Unify not-found payload with shared API v1 contract.

Line 40 returns a custom 404 shape (error: "not_found"), which can drift from the shared API record_not_found contract and create client-side branching for the same status. Prefer letting ActiveRecord::RecordNotFound bubble to the base handler (or match its payload exactly).

♻️ Proposed fix
 def set_holding
   `@holding` = accessible_holdings.find(params[:id])
-rescue ActiveRecord::RecordNotFound
-  render json: { error: "not_found", message: "Holding not found" }, status: :not_found
 end
🤖 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 `@app/controllers/api/v1/holdings_controller.rb` around lines 39 - 41, The
rescue block for ActiveRecord::RecordNotFound in the holdings_controller returns
a custom error payload that diverges from the shared API v1 contract for
record_not_found responses. Remove this rescue block entirely (the rescue
ActiveRecord::RecordNotFound block at lines 39-41) and allow the exception to
bubble up to the base/shared API error handler, which already handles this
exception with the standardized payload format expected by API clients.
🤖 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 `@test/controllers/api/v1/holdings_controller_test.rb`:
- Around line 50-78: Add two new test cases to the holdings controller test file
to cover missing behavioral scenarios. First, add a test similar to the existing
"account_ids filter cannot reach inaccessible accounts" test but using the
singular account_id parameter instead of account_ids to ensure the same security
restriction applies. Second, add a test that passes an invalid date parameter to
the API endpoint and verifies it returns a 422 Unprocessable Entity response,
ensuring proper validation of date inputs on the endpoint.

---

Nitpick comments:
In `@app/controllers/api/v1/holdings_controller.rb`:
- Around line 39-41: The rescue block for ActiveRecord::RecordNotFound in the
holdings_controller returns a custom error payload that diverges from the shared
API v1 contract for record_not_found responses. Remove this rescue block
entirely (the rescue ActiveRecord::RecordNotFound block at lines 39-41) and
allow the exception to bubble up to the base/shared API error handler, which
already handles this exception with the standardized payload format expected by
API clients.
🪄 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: aa81e0b8-20bf-41f6-a66f-fd7a121a6dd8

📥 Commits

Reviewing files that changed from the base of the PR and between fdcd0c7 and 9d35b6e.

📒 Files selected for processing (2)
  • app/controllers/api/v1/holdings_controller.rb
  • test/controllers/api/v1/holdings_controller_test.rb

Comment on lines +50 to +78
test "account_ids filter cannot reach inaccessible accounts" do
get api_v1_holdings_url,
params: { account_ids: [ @inaccessible_account.id ] },
headers: api_headers(@api_key)

assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |h| h["id"] }
assert_not_includes holding_ids, @inaccessible_holding.id
end

test "shows an accessible holding" do
get api_v1_holding_url(@holding), headers: api_headers(@api_key)

assert_response :success
response_data = JSON.parse(response.body)
assert_equal @holding.id, response_data["id"]
end

test "returns not found for an inaccessible holding" do
get api_v1_holding_url(@inaccessible_holding), headers: api_headers(@api_key)

assert_response :not_found
end

test "requires authentication" do
get api_v1_holdings_url

assert_response :unauthorized
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add missing API-v1 behavioral coverage for account_id and invalid date.

The suite currently guards account_ids but not account_id, and it misses the invalid-date 422 path. Both are important regression guards for this endpoint’s filter surface.

✅ Suggested test additions
+  test "account_id filter cannot reach an inaccessible account" do
+    get api_v1_holdings_url,
+        params: { account_id: `@inaccessible_account.id` },
+        headers: api_headers(`@api_key`)
+
+    assert_response :success
+    holding_ids = JSON.parse(response.body)["holdings"].map { |h| h["id"] }
+    assert_not_includes holding_ids, `@inaccessible_holding.id`
+  end
+
+  test "returns unprocessable entity for invalid date filter" do
+    get api_v1_holdings_url,
+        params: { date: "not-a-date" },
+        headers: api_headers(`@api_key`)
+
+    assert_response :unprocessable_entity
+  end

As per coding guidelines, test/controllers/api/v1/**/*_controller_test.rb must cover key API behaviors including invalid date (422) scenarios.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
test "account_ids filter cannot reach inaccessible accounts" do
get api_v1_holdings_url,
params: { account_ids: [ @inaccessible_account.id ] },
headers: api_headers(@api_key)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |h| h["id"] }
assert_not_includes holding_ids, @inaccessible_holding.id
end
test "shows an accessible holding" do
get api_v1_holding_url(@holding), headers: api_headers(@api_key)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal @holding.id, response_data["id"]
end
test "returns not found for an inaccessible holding" do
get api_v1_holding_url(@inaccessible_holding), headers: api_headers(@api_key)
assert_response :not_found
end
test "requires authentication" do
get api_v1_holdings_url
assert_response :unauthorized
end
test "account_ids filter cannot reach inaccessible accounts" do
get api_v1_holdings_url,
params: { account_ids: [ `@inaccessible_account.id` ] },
headers: api_headers(`@api_key`)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |h| h["id"] }
assert_not_includes holding_ids, `@inaccessible_holding.id`
end
test "shows an accessible holding" do
get api_v1_holding_url(`@holding`), headers: api_headers(`@api_key`)
assert_response :success
response_data = JSON.parse(response.body)
assert_equal `@holding.id`, response_data["id"]
end
test "returns not found for an inaccessible holding" do
get api_v1_holding_url(`@inaccessible_holding`), headers: api_headers(`@api_key`)
assert_response :not_found
end
test "requires authentication" do
get api_v1_holdings_url
assert_response :unauthorized
end
test "account_id filter cannot reach an inaccessible account" do
get api_v1_holdings_url,
params: { account_id: `@inaccessible_account.id` },
headers: api_headers(`@api_key`)
assert_response :success
holding_ids = JSON.parse(response.body)["holdings"].map { |h| h["id"] }
assert_not_includes holding_ids, `@inaccessible_holding.id`
end
test "returns unprocessable entity for invalid date filter" do
get api_v1_holdings_url,
params: { date: "not-a-date" },
headers: api_headers(`@api_key`)
assert_response :unprocessable_entity
end
🤖 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 `@test/controllers/api/v1/holdings_controller_test.rb` around lines 50 - 78,
Add two new test cases to the holdings controller test file to cover missing
behavioral scenarios. First, add a test similar to the existing "account_ids
filter cannot reach inaccessible accounts" test but using the singular
account_id parameter instead of account_ids to ensure the same security
restriction applies. Second, add a test that passes an invalid date parameter to
the API endpoint and verifies it returns a 422 Unprocessable Entity response,
ensuring proper validation of date inputs on the endpoint.

Source: Coding guidelines

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Holdings API exposes positions from accounts not shared with the requesting user

1 participant