33
44import json
55import logging
6+
7+ import httpx
8+ import sqlglot
69from typing import Optional , Dict , Sequence , List
7- from fastapi import HTTPException
10+ from fastapi import HTTPException , Request
811from sqlalchemy import RowMapping , Table , text , select , func
912from sqlalchemy .ext .asyncio import AsyncSession
1013from sqlalchemy .exc import DBAPIError
1922)
2023from celine .dataset .security .models import AuthenticatedUser
2124from celine .dataset .api .dataset_query .parser import parse_sql_query
22- from celine .dataset .api .dataset_query .user_filter import (
23- get_user_filter_column ,
24- inject_user_filter ,
25- is_admin_user ,
25+ from celine .dataset .api .dataset_query .row_filters import (
26+ apply_row_filter_plans ,
27+ get_row_filter_registry ,
28+ get_row_filter_specs ,
2629)
30+ from celine .dataset .api .dataset_query .row_filters .utils import is_admin_user
2731
2832logger = logging .getLogger (__name__ )
2933
@@ -103,20 +107,15 @@ async def execute_query(
103107 - SQL validated (SELECT-only, table allowlist)
104108 - LIMIT/OFFSET enforced server-side
105109 - hard row cap applied
106- - user filtering applied (if userFilterColumn defined )
110+ - row-level filters applied (pluggable governance handlers )
107111 """
108- # ------------------------------------------------------------------
109- # Validate SQL
110- # ------------------------------------------------------------------
111-
112112 if raw_sql is None or raw_sql .strip () == "" :
113113 raise HTTPException (400 , "sql query not provided" )
114114
115115 logger .debug (f"Parsing raw SQL: { raw_sql } " )
116116 try :
117117 parsed = parse_sql_query (raw_sql )
118- except HTTPException as exc :
119- logger .error (f"SQL validation failed: { exc } " )
118+ except HTTPException :
120119 raise
121120 except Exception as exc :
122121 logger .exception ("SQL validation failed" )
@@ -126,12 +125,16 @@ async def execute_query(
126125 raise HTTPException (400 , "Query references no datasets" )
127126
128127 datasets = await resolve_datasets_for_tables (db = db , table_names = parsed .tables )
128+
129129 tables_map : dict [str , str ] = {}
130- user_filters : List [dict ] = [] # Collect filters to apply
130+ row_filter_plans = []
131+
132+ registry = get_row_filter_registry ()
131133
132134 for ref_table , ds in datasets .items ():
133135 if not ds .expose :
134136 raise HTTPException (403 , "Dataset not available" )
137+
135138 await enforce_dataset_access (entry = ds , user = user )
136139
137140 if ds .backend_config is None :
@@ -148,45 +151,80 @@ async def execute_query(
148151 logger .debug (f"Mapped SQL table { ref_table } -> { phy_table_name } " )
149152 tables_map [ref_table ] = phy_table_name
150153
151- # ------------------------------------------------------------------
152- # Check for user filtering requirement
153- # ------------------------------------------------------------------
154- filter_column = get_user_filter_column (ds )
155- if filter_column :
156- if user is None :
154+ specs = get_row_filter_specs (ds )
155+ if not specs :
156+ continue
157+
158+ if user is None :
159+ raise HTTPException (
160+ 401 ,
161+ f"Dataset { ref_table } requires authentication for row filtering" ,
162+ )
163+
164+ if is_admin_user (user ):
165+ continue
166+
167+ for spec in specs :
168+ handler_name = spec .get ("handler" )
169+ args = spec .get ("args" ) or {}
170+ if not isinstance (handler_name , str ) or not handler_name :
157171 raise HTTPException (
158- 401 ,
159- f"Dataset { ref_table } requires authentication for user filtering" ,
172+ 500 ,
173+ f"Invalid row filter spec for dataset { ref_table } : missing handler" ,
174+ )
175+ if not isinstance (args , dict ):
176+ raise HTTPException (
177+ 500 ,
178+ f"Invalid row filter spec for dataset { ref_table } : args must be object" ,
160179 )
161180
162- # Admins bypass user filtering
163- if not is_admin_user (user ):
164- user_filters .append (
165- {
166- "table" : phy_table_name ,
167- "column" : filter_column ,
168- "user_sub" : user .sub ,
169- }
181+ try :
182+ plan = await registry .resolve_with_cache (
183+ handler_name = handler_name ,
184+ table = phy_table_name ,
185+ user = user ,
186+ args = args ,
187+ request_context = {},
188+ )
189+ except KeyError :
190+ logger .error (
191+ f"Unknown row filter handler '{ handler_name } ' for dataset { ref_table } "
192+ )
193+ raise HTTPException (
194+ 500 ,
195+ f"Unknown row filter handler '{ handler_name } ' for dataset { ref_table } " ,
170196 )
171- logger .debug (
172- f"User filter required for { ref_table } : "
173- f"{ filter_column } = { user .sub } "
197+ except httpx .HTTPError :
198+ logger .error (f"Row filter resolution failed for dataset { ref_table } " )
199+ raise HTTPException (
200+ 403 ,
201+ f"Row filter resolution failed for dataset { ref_table } " ,
202+ )
203+ except Exception as e :
204+ logger .error (f"Row filter handler failed: { e } " )
205+ raise HTTPException (
206+ 500 ,
207+ f"Row filter handler '{ handler_name } ' failed for dataset { ref_table } " ,
174208 )
175209
176- # Replace tables ID with physical tables
177- complete_sql = parsed .to_sql (tables_map = tables_map )
178- logger .debug (f"Complete SQL (before user filter): { complete_sql } " )
210+ row_filter_plans .append (plan )
179211
180- # ------------------------------------------------------------------
181- # Inject user filters
182- # ------------------------------------------------------------------
183- if user_filters :
184- complete_sql = inject_user_filter (complete_sql , user_filters )
185- logger .debug (f"Complete SQL (after user filter): { complete_sql } " )
212+ # Logical -> physical substitution
213+ complete_sql = parsed .to_sql (tables_map = tables_map )
214+ logger .debug (f"Complete SQL (after table mapping): { complete_sql } " )
215+
216+ # Apply row-level filters
217+ if row_filter_plans :
218+ try :
219+ ast = sqlglot .parse_one (complete_sql )
220+ ast = apply_row_filter_plans (ast , row_filter_plans )
221+ complete_sql = ast .sql ()
222+ except Exception :
223+ logger .exception ("Failed to apply row filters" )
224+ raise HTTPException (500 , "Failed to apply row filters" ) from None
225+ logger .debug (f"Complete SQL (after row filters): { complete_sql } " )
186226
187- # ------------------------------------------------------------------
188227 # Pagination & caps
189- # ------------------------------------------------------------------
190228 limit = _clamp_limit (limit )
191229 offset = max (offset , 0 )
192230
@@ -204,40 +242,29 @@ async def execute_query(
204242 ) AS q
205243 """
206244
207- # ------------------------------------------------------------------
208245 # Execute count
209- # ------------------------------------------------------------------
210246 try :
211- total = await execute_scalar_with_timeout (
212- db ,
213- count_sql ,
214- )
215- except HTTPException as e :
216- logger .error (f"Query count failed: { e } " )
247+ total = await execute_scalar_with_timeout (db , count_sql )
248+ except HTTPException :
217249 raise
218- except Exception as exc : # safety net
250+ except Exception :
219251 logger .exception ("Count query failed" )
220252 raise HTTPException (500 , "Query failed" ) from None
221253
222- # ------------------------------------------------------------------
223254 # Execute data query
224- # ------------------------------------------------------------------
225255 try :
226256 rows = await execute_rows_with_timeout (
227257 db ,
228258 paginated_sql ,
229259 {"limit" : limit , "offset" : offset },
230260 )
231- except HTTPException as e :
232- logger .error (f"Query execution failed: { e } " )
261+ except HTTPException :
233262 raise
234- except Exception as exc : # safety net
235- logger .error ("Query execution failed: {e} " )
263+ except Exception :
264+ logger .exception ("Query execution failed" )
236265 raise HTTPException (500 , "Query execution failed" ) from None
237266
238- # ------------------------------------------------------------------
239267 # Post-process rows (geometry → GeoJSON)
240- # ------------------------------------------------------------------
241268 items = []
242269 for r in rows :
243270 row = dict (r )
0 commit comments