mirror of
https://github.com/we-promise/sure.git
synced 2026-07-27 20:22:16 +00:00
Api::V1::HoldingsController built both the index and show queries from current_resource_owner.family.holdings, filtering only on account status. A read-scoped API key could therefore read holdings (account id/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 of those accounts trivial. Scope the query through accounts.accessible_by(current_resource_owner) so results are limited to accounts the token owner owns or has been granted access to. Route both index and set_holding through the new accessible_holdings scope. This matches Api::V1::BalancesController and the web HoldingsController, which already scope the same way. Fixes #2467 Co-authored-by: agentloop <agentloop@localhost>
120 lines
3.3 KiB
Ruby
120 lines
3.3 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Api::V1::HoldingsController < Api::V1::BaseController
|
|
include Pagy::Backend
|
|
|
|
before_action :ensure_read_scope
|
|
before_action :set_holding, only: [ :show ]
|
|
|
|
def index
|
|
holdings_query = accessible_holdings
|
|
|
|
holdings_query = apply_filters(holdings_query)
|
|
holdings_query = holdings_query.includes(:account, :security).chronological
|
|
|
|
@pagy, @holdings = pagy(
|
|
holdings_query,
|
|
page: safe_page_param,
|
|
limit: safe_per_page_param
|
|
)
|
|
@per_page = safe_per_page_param
|
|
|
|
render :index
|
|
rescue ArgumentError => e
|
|
render_validation_error(e.message, [ e.message ])
|
|
rescue => e
|
|
log_and_render_error("index", e)
|
|
end
|
|
|
|
def show
|
|
render :show
|
|
rescue => e
|
|
log_and_render_error("show", e)
|
|
end
|
|
|
|
private
|
|
|
|
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
|
|
|
|
# Holdings restricted to accounts the token owner can access (owned or shared),
|
|
# not the whole family. Mirrors Api::V1::BalancesController and the web
|
|
# HoldingsController, both of which scope through Account.accessible_by.
|
|
def accessible_holdings
|
|
Holding
|
|
.joins(:account)
|
|
.where(accounts: { status: %w[draft active], id: accessible_account_ids })
|
|
end
|
|
|
|
def accessible_account_ids
|
|
@accessible_account_ids ||= current_resource_owner.family.accounts.accessible_by(current_resource_owner).select(:id)
|
|
end
|
|
|
|
def ensure_read_scope
|
|
authorize_scope!(:read)
|
|
end
|
|
|
|
def apply_filters(query)
|
|
if params[:account_id].present?
|
|
query = query.where(account_id: params[:account_id])
|
|
end
|
|
if params[:account_ids].present?
|
|
query = query.where(account_id: Array(params[:account_ids]))
|
|
end
|
|
if params[:date].present?
|
|
query = query.where(date: parse_date!(params[:date], "date"))
|
|
end
|
|
if params[:start_date].present?
|
|
query = query.where("holdings.date >= ?", parse_date!(params[:start_date], "start_date"))
|
|
end
|
|
if params[:end_date].present?
|
|
query = query.where("holdings.date <= ?", parse_date!(params[:end_date], "end_date"))
|
|
end
|
|
if params[:security_id].present?
|
|
query = query.where(security_id: params[:security_id])
|
|
end
|
|
query
|
|
end
|
|
|
|
def safe_page_param
|
|
page = params[:page].to_i
|
|
page > 0 ? page : 1
|
|
end
|
|
|
|
def safe_per_page_param
|
|
per_page = params[:per_page].to_i
|
|
case per_page
|
|
when 1..100
|
|
per_page
|
|
else
|
|
25
|
|
end
|
|
end
|
|
|
|
def parse_date!(value, param_name)
|
|
Date.parse(value)
|
|
rescue Date::Error, ArgumentError, TypeError
|
|
raise ArgumentError, "Invalid #{param_name} format"
|
|
end
|
|
|
|
def render_validation_error(message, errors)
|
|
render json: {
|
|
error: "validation_failed",
|
|
message: message,
|
|
errors: errors
|
|
}, status: :unprocessable_entity
|
|
end
|
|
|
|
def log_and_render_error(action, exception)
|
|
Rails.logger.error "HoldingsController##{action} error: #{exception.message}"
|
|
Rails.logger.error exception.backtrace.join("\n")
|
|
render json: {
|
|
error: "internal_server_error",
|
|
message: "An unexpected error occurred"
|
|
}, status: :internal_server_error
|
|
end
|
|
end
|