"""Shared request handling for account-scoped batch /dataloader endpoints. Every account-scoped dataloader endpoint shares one head: parse a raw id array, cap it, resolve the account for each id, run the two-tier account authorization over the resolved set, fetch records for the authorized ids only, and shape them into the ordered dataload response. Endpoints supply how to resolve accounts and how to fetch records; the cap, authorization, authorized-only scoping, and response shaping live here so they cannot be forgotten or drift per endpoint. The fetch callback is only ever handed the authorized ids, so a copy cannot leak data for an unauthorized or account-less id. """ from collections.abc import Callable from typing import Any from abacus_common_logic.utils.authorization import permissions_authorize_many_accounts from flask import Response, g, request from owsrequest import flask_request from owsresponse import response from owsresponse.adaptors.flask import flaskify from abacus_contract.constants import error from abacus_contract.utils import authorization from abacus_contract.utils.format_error import validation_error from abacus_contract.utils.format_response import ( prepare_dataload_response, prepare_dataload_with_data_as_list_response, ) from abacus_contract.utils.request import get_optional_numeric_list_from_params from core.config import Config, ows_client from core.hardening import observability def _forbidden() -> Response: return flaskify( response.create_error_response( code=error.ERROR_CODE_FORBIDDEN, message=error.ERROR_MESSAGE_FORBIDDEN_USER, status=403, ) ) def account_scoped_dataloader( entity_name: str, resolve_accounts: Callable[[list[int]], dict[int, int | None]], fetch_records: Callable[[list[int]], list[dict[str, Any]]], key_field: str, as_list: bool = False, ) -> Response: """Handle a batch ``/dataloader`` request: parse, cap, authorize, fetch, shape. Authorization is all-or-nothing over the batch: if the caller is not authorized for every resolved account, the whole request is rejected with 403 (matching the existing precedent for the other batch/dataloader endpoints). Id *resolution* is per-id -- ids with no resolvable account are dropped individually and surface as ``data: None`` -- so the two are intentionally different granularities. Args: entity_name: name for the invalid-ids error (e.g. ``'ContractTerm'``). resolve_accounts: ``ids -> {id: account_id}``. Ids with no resolvable account may be omitted or mapped to ``None``; either way they are dropped from authorization and the fetch and surface as ``data: None``. fetch_records: ``authorized_ids -> list[dict]`` where each record carries ``key_field``. Only authorized ids are ever passed, so a record for an unauthorized or account-less id can never be produced. key_field: record field naming the parent id, used to group and order. as_list: ``True`` for one-to-many (``data`` is a list per id); ``False`` for one-to-one. """ try: ids = get_optional_numeric_list_from_params() except (ValueError, TypeError): # TypeError as well as ValueError: a malformed body element that is not a # scalar (e.g. [1, [2]]) makes int() raise TypeError, which would # otherwise surface as a 500 on a client-supplied batch endpoint. return flaskify( validation_error(error.ERROR_INVALID_IDS.format(object=entity_name)) ) if len(ids) > Config.OWS_BATCH_LIMIT: return flaskify( response.create_error_response( code=error.ERROR_CODE_BAD_REQUEST, message=error.ERROR_TOO_MANY_DATALOADER_IDS.format( limit=Config.OWS_BATCH_LIMIT ), status=400, ) ) accounts_by_id = resolve_accounts(ids) authorized_ids = [i for i in ids if accounts_by_id.get(i) is not None] account_ids = list({accounts_by_id[i] for i in authorized_ids}) unresolved_count = len(ids) - len(authorized_ids) # The endpoint's URL rule disambiguates dataloaders that share an entity name # (several contract dataloaders all pass entity='Contract') so per-endpoint # metrics are not blended into one entity series. route = request.url_rule.rule if request.url_rule else 'unknown' # Metrics for the unresolved *ratio*, which the info log can't be alerted on; # a spike toward 1.0 is the abnormal 'resolver returned None for everyone' # case. ids is non-empty here (an empty body already 400s at parse). observability.dataloader_batch(entity_name, route, len(ids), unresolved_count) if unresolved_count: # Partial non-resolution is a normal, expected state for a # client-supplied id list (stale cache, an id behind an authz # boundary, a soft-deleted row), so this is info, not warn: warn would # fire on essentially every broad query and become alert-fatigue noise. g.log.info( f'{entity_name} dataloader: {unresolved_count} of {len(ids)} ids did ' 'not resolve to an account and were returned as null.' ) if account_ids: if not flask_request.verify_rules_access_standalone(request): if not authorization.pdp_authorize_many_accounts(account_ids): return _forbidden() if not permissions_authorize_many_accounts( ows_client, g.request_context.profile_type, g.request_context.profile_id, account_ids, ): return _forbidden() records = fetch_records(authorized_ids) if records and not any(record.get(key_field) is not None for record in records): observability.dataloader_key_field_mismatch(entity_name, route) # A non-empty fetch where no record carries a usable key_field value is # almost always a copy-paste bug (typo, or a serializer emitting a # differently named id field): the shaper groups on record.get(key_field), # so every id would miss and the batch would ship a 200 with data: null # for everyone, silently. Check the value, not just the key's presence, # since a key that is always None fails the same way. Surface it loudly. g.log.error( f'{entity_name} dataloader: fetch_records returned {len(records)} ' f'record(s), none carrying key_field={key_field!r}; likely a ' 'field-name mismatch. Every entry will be shaped to null.' ) shape = ( prepare_dataload_with_data_as_list_response if as_list else prepare_dataload_response ) return flaskify( response.Response(message=shape(ids, records, key_field), status=200) )