From 2d5f9e7a3e4d59f1d4643a61d117467aee55efb6 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Thu, 4 Jun 2026 00:06:24 +0000 Subject: [PATCH] Upload app/services/_base.py --- app/services/_base.py | 79 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 app/services/_base.py diff --git a/app/services/_base.py b/app/services/_base.py new file mode 100644 index 0000000..47e7416 --- /dev/null +++ b/app/services/_base.py @@ -0,0 +1,79 @@ +"""OrgScopedQuery helper: central tenant-isolation layer (R-1 mitigation). + +Every business query passes through this helper so that: +- `org_id` is always applied (multi-tenant isolation) +- `deleted_at IS NULL` is applied by default (soft-delete) +- R-1 risk is mitigated: no query accidentally crosses tenant boundaries + +In v1 (single-tenant) org_id defaults to 1. In v2 (multi-tenant) the +router layer passes `current_user.org_id`. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + + +class OrgScopedQuery: + """Zentraler Query-Helper, der jede Query mit org_id filtert. + + v1: org_id = 1 (Single-Tenant-Default) + v2: org_id = current_user.org_id + """ + + def __init__(self, model: type[Any], db: AsyncSession, org_id: int = 1) -> None: + self.model = model + self.db = db + self.org_id = org_id + + async def get(self, id: int) -> Any: + """Fetch a single record by id, scoped to org and not soft-deleted.""" + result = await self.db.execute( + select(self.model).where( + self.model.id == id, + self.model.org_id == self.org_id, + self.model.deleted_at.is_(None), + ) + ) + return result.scalar_one_or_none() + + async def list( + self, + skip: int = 0, + limit: int = 20, + order_by: Optional[Any] = None, + **filters: Any, + ) -> list[Any]: + """List records scoped to org, with optional filters and pagination.""" + stmt = select(self.model).where( + self.model.org_id == self.org_id, + self.model.deleted_at.is_(None), + ) + for field, value in filters.items(): + if value is not None: + stmt = stmt.where(getattr(self.model, field) == value) + if order_by is not None: + stmt = stmt.order_by(order_by) + stmt = stmt.offset(skip).limit(limit) + result = await self.db.execute(stmt) + return list(result.scalars().all()) + + async def count(self, **filters: Any) -> int: + """Count records scoped to org, with optional filters.""" + from sqlalchemy import func as sa_func + + stmt = select(sa_func.count()).select_from(self.model).where( + self.model.org_id == self.org_id, + self.model.deleted_at.is_(None), + ) + for field, value in filters.items(): + if value is not None: + stmt = stmt.where(getattr(self.model, field) == value) + result = await self.db.execute(stmt) + return int(result.scalar_one()) + + +__all__ = ["OrgScopedQuery"]