Ctrl K
ComunidadGuías…

app/tenancy.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""Automatic per-portfolio filtering of the tables holding personal data.

Every `select()` on a session carrying a portfolio id gets a
`WHERE portfolio_id = :pid` on the models in `SCOPED_MODELS` — top-level
queries, eager loads and lazy loads alike.

It fails closed: a select touching a scoped model with no portfolio id on the
session raises. `unscoped_session()` is the only opt-out, and is greppable.

Writes are not covered — SQLAlchemy applies loader criteria to selects only —
so inserts set `portfolio_id` via `owned()`, with a before_flush net in
`install()`.

This separates users *within the application*, not from anyone holding the
database: see decisions.md #63 (Trust boundary).
"""

from __future__ import annotations

from contextlib import contextmanager
from typing import Iterator

from sqlalchemy import event, select
from sqlalchemy import false as sql_false
from sqlalchemy.orm import Session, with_loader_criteria

from .models import (
    MEMBER_ROLES,
    CorporateAction,
    Dividend,
    HoldingPref,
    InvestmentPlan,
    PlannedPurchase,
    PortfolioMember,
    SavedChart,
    Trade,
)

# Every model with a portfolio_id column. Adding a personal-data table? Add it
# here — that is the whole registration step.
SCOPED_MODELS = (
    Trade,
    Dividend,
    CorporateAction,
    PlannedPurchase,
    HoldingPref,
    InvestmentPlan,
)

# Owned by a person rather than a portfolio (their charts follow them between
# portfolios). Filtered the same way, on user_id — being in this tuple is what
# keeps them inside the fail-closed guarantee rather than relying on every
# query remembering a where clause.
USER_SCOPED_MODELS = (SavedChart,)

_PORTFOLIO_KEY = "portfolio_id"
_USER_KEY = "portfolio_user_id"
_BYPASS_KEY = "portfolio_unscoped"


class TenancyError(RuntimeError):
    """Raised when personal data is queried with no portfolio context."""


_UNBOUND_MESSAGE = (
    "query touches portfolio data with no portfolio on the session — use "
    "portfolio_session(...) or, for market-data maintenance, unscoped_session(...)"
)


def install(session_factory) -> None:
    """Attach the filter to a sessionmaker. Call once at startup."""

    @event.listens_for(session_factory, "before_flush")
    def _stamp_owner(session, _ctx, _instances) -> None:
        """New personal rows inherit the session's portfolio, and record who
        entered them.

        `owned()` says it explicitly at the call sites that matter; this is the
        safety net for bulk paths (the workbook importer creates rows in eight
        places) and for anything added later. Only fills blanks — it never
        reassigns an existing owner.
        """
        portfolio_id = session.info.get(_PORTFOLIO_KEY)
        user_id = session.info.get(_USER_KEY)
        if portfolio_id is None:
            return
        for obj in session.new:
            if not isinstance(obj, SCOPED_MODELS):
                continue
            if obj.portfolio_id is None:
                obj.portfolio_id = portfolio_id
            if user_id is not None and getattr(obj, "user_id", "absent") is None:
                obj.user_id = user_id

    @event.listens_for(session_factory, "do_orm_execute")
    def _apply_portfolio_filter(state) -> None:
        if not state.is_select or state.is_column_load:
            return
        session = state.session
        if session.info.get(_BYPASS_KEY):
            return
        portfolio_id = session.info.get(_PORTFOLIO_KEY)
        # A relationship load inherits the criteria from the query that loaded
        # its parent (propagate_to_loaders), so re-applying them here would
        # double up. But an UNBOUND session has no criteria to inherit, and a
        # lazy `instrument.trades` would then hand back every portfolio's rows
        # — so the refusal below still has to run before we return.
        if state.is_relationship_load:
            if portfolio_id is None and _touches_scoped(state):
                raise TenancyError(_UNBOUND_MESSAGE)
            return
        if portfolio_id is None:
            # No portfolio bound: fine for market data (the price feed runs this
            # way), a bug for anything personal.
            if _touches_scoped(state):
                raise TenancyError(_UNBOUND_MESSAGE)
            return
        # Applied to EVERY statement, not just ones naming a scoped model:
        # `select(Instrument)` carries the criteria forward to a later
        # `instrument.trades` lazy load (propagate_to_loaders). Skipping it here
        # because the statement "doesn't touch trades" is exactly how that lazy
        # load would come back unfiltered.
        for model in SCOPED_MODELS:
            state.statement = state.statement.options(
                with_loader_criteria(
                    model, model.portfolio_id == portfolio_id, include_aliases=True
                )
            )
        # User-scoped models are filtered whether or not a user is bound. With
        # no user, the criterion matches nothing rather than everything: a
        # session carrying a portfolio but no user (an API key whose creator
        # was deleted, nulling `created_by`) would otherwise read EVERY user's
        # saved charts.
        user_id = session.info.get(_USER_KEY)
        for model in USER_SCOPED_MODELS:
            criterion = (
                model.user_id == user_id if user_id is not None else sql_false()
            )
            state.statement = state.statement.options(
                with_loader_criteria(model, criterion, include_aliases=True)
            )


def _touches_scoped(state) -> bool:
    """True when the statement selects, or eager-loads, a scoped model.

    Cheap over-approximation: match on the mapper of every entity in the
    statement. Instrument-only queries (price feed, catalogue) skip the check so
    they keep working outside a request.
    """
    scoped = set(SCOPED_MODELS) | set(USER_SCOPED_MODELS)
    for desc in state.statement.column_descriptions:
        if desc.get("entity") in scoped:
            return True
    # A scoped table reached through FROM or JOIN rather than named as the
    # selected entity: `select(func.count()).select_from(Trade)` selects a
    # number, not a Trade, and `select(Instrument).join(Trade)` selects an
    # instrument — both read personal rows and neither shows up above.
    # Compared by table, because that is what a JOIN carries.
    scoped_tables = {model.__table__ for model in scoped}
    for element in getattr(state.statement, "get_final_froms", lambda: ())():
        if _mentions(element, scoped_tables):
            return True
    # Eager loads name their target in the statement's _with_options; simplest
    # reliable signal is the ORM path they walk, every step of it, so a scoped
    # model in the middle of a nested load counts too.
    for opt in getattr(state.statement, "_with_options", ()):
        for attr in getattr(opt, "path", ()) or ():
            if getattr(getattr(attr, "entity", None), "class_", None) in scoped:
                return True
    return False


def _mentions(element, tables: set) -> bool:
    """Whether a FROM element is, or joins, one of these tables."""
    if element in tables:
        return True
    left, right = getattr(element, "left", None), getattr(element, "right", None)
    if left is not None and _mentions(left, tables):
        return True
    if right is not None and _mentions(right, tables):
        return True
    # A subquery keeps its own SELECT, which may reach a scoped table too.
    inner = getattr(getattr(element, "element", None), "get_final_froms", None)
    if inner is not None:
        return any(_mentions(f, tables) for f in inner())
    return False


def bind(session: Session, portfolio_id: int, user_id: int | None = None) -> Session:
    """Confine an already-open session to one portfolio's rows.

    `user_id` is stamped onto new rows as "recorded by" — it plays no part in
    what the session can read.
    """
    session.info[_PORTFOLIO_KEY] = portfolio_id
    session.info[_USER_KEY] = user_id
    return session


def allow_unscoped(session: Session) -> Session:
    """Drop the filter on an open session. Two callers only: the first-run
    claim of pre-auth rows (which have no owner yet, so no filter could find
    them) and the price feed's cross-portfolio FX repair."""
    session.info[_BYPASS_KEY] = True
    return session


@contextmanager
def as_portfolio(
    session: Session,
    portfolio_id: int,
    *,
    user_id: int | None,
    roles: tuple[str, ...] = MEMBER_ROLES,
) -> Iterator[Session]:
    """Read another of THIS USER's portfolios for the length of the block.

    The one operation that spans two portfolios is moving a holding between
    them, and it must validate the destination's timeline before writing there.
    `unscoped_session` is the wrong tool for it: that drops the filter for
    every portfolio at once and says never to serve a request with it.

    The membership check is HERE, not in the caller. A primitive that rebinds
    the filter on request is a cross-tenant read waiting for one careless call
    site — so it refuses unless `user_id` holds a role in `roles`, and callers
    that are about to WRITE pass `roles=WRITE_ROLES` rather than checking
    separately and hoping the two agree.

    Reads inside the block must use `select()`. `session.get()` can answer from
    the identity map without going near the filter, so it would hand back a row
    belonging to whichever portfolio loaded it first.
    """
    if user_id is None:
        raise TenancyError("cannot reach another portfolio with no user")
    role = session.scalar(
        select(PortfolioMember.role).where(
            PortfolioMember.portfolio_id == portfolio_id,
            PortfolioMember.user_id == user_id,
        )
    )
    if role is None or role not in roles:
        # One message for "not a member" and "not allowed to write", because
        # telling them apart would confirm the portfolio exists.
        raise TenancyError(
            f"user {user_id} may not reach portfolio {portfolio_id} as {roles}"
        )
    was = session.info.get(_PORTFOLIO_KEY)
    session.info[_PORTFOLIO_KEY] = portfolio_id
    try:
        yield session
    finally:
        # Restored even when the block raises: a failed validation must not
        # leave the rest of the request reading the wrong portfolio.
        session.info[_PORTFOLIO_KEY] = was


@contextmanager
def portfolio_session(
    session_factory, portfolio_id: int, user_id: int | None = None
) -> Iterator[Session]:
    """A session whose reads are confined to one portfolio. Commits on success."""
    session = session_factory()
    session.info[_PORTFOLIO_KEY] = portfolio_id
    session.info[_USER_KEY] = user_id
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


@contextmanager
def unscoped_session(session_factory) -> Iterator[Session]:
    """Every portfolio's rows — market-data maintenance only (the price feed's
    trade-FX backfill). Never use this to serve a request."""
    session = session_factory()
    session.info[_BYPASS_KEY] = True
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()


def current_portfolio_id(session: Session) -> int | None:
    return session.info.get(_PORTFOLIO_KEY)


def current_user_id(session: Session) -> int | None:
    return session.info.get(_USER_KEY)


def owned(session: Session, obj):
    """Stamp a new row with the session's portfolio (and recorder) before add."""
    from .models import SavedChart as _SavedChart

    if isinstance(obj, _SavedChart):
        user_id = current_user_id(session)
        if user_id is None:
            raise TenancyError("cannot create a chart with no user context")
        obj.user_id = user_id
        obj.portfolio_id = current_portfolio_id(session)
        return obj
    portfolio_id = current_portfolio_id(session)
    if portfolio_id is None:
        raise TenancyError(
            f"cannot create {type(obj).__name__} with no portfolio context"
        )
    obj.portfolio_id = portfolio_id
    user_id = current_user_id(session)
    if user_id is not None and hasattr(obj, "user_id"):
        obj.user_id = user_id
    return obj