from __future__ import annotations from typing import Any from fastapi import HTTPException, status from sqlalchemy import and_, func, not_, or_, select from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.models.agent_run import AgentRun from app.schemas.agent_run import AgentRunRead from app.services.finance_dashboard_access_policy import FinanceDashboardAccessPolicy from app.services.finance_dashboard_scope import ( FINANCE_DASHBOARD_TASK_TYPE, resolve_finance_dashboard_data_scope, ) class AgentRunAccessPolicy: """对租户边界和敏感领域权限做返回前的第二层校验。""" @classmethod def build_query_scope(cls, current_user: CurrentUserContext) -> Any: """把财务快照领域门禁下推到 limit 之前,避免不可见记录挤占窗口。""" tenant_id = cls.require_current_tenant_id(current_user) route_task_type = func.coalesce( AgentRun.route_json["task_type"].as_string(), "", ) route_job_type = func.coalesce( AgentRun.route_json["job_type"].as_string(), "", ) is_finance_snapshot = or_( route_task_type == FINANCE_DASHBOARD_TASK_TYPE, route_job_type == FINANCE_DASHBOARD_TASK_TYPE, ) if not FinanceDashboardAccessPolicy.can_read(current_user): return not_(is_finance_snapshot) expected_data_scope = resolve_finance_dashboard_data_scope(tenant_id) valid_finance_scope = and_( AgentRun.route_json["tenant_id"].as_string() == tenant_id, AgentRun.ontology_json["tenant_id"].as_string() == tenant_id, AgentRun.route_json["data_scope"].as_string() == expected_data_scope, AgentRun.ontology_json["data_scope"].as_string() == expected_data_scope, ) return or_(not_(is_finance_snapshot), valid_finance_scope) @classmethod def filter_list_items( cls, runs: list[AgentRunRead], current_user: CurrentUserContext, db: Session, ) -> list[AgentRunRead]: payloads_by_run_id = cls._run_scope_payloads( db, [run.run_id for run in runs], ) current_tenant_id = cls.require_current_tenant_id(current_user) visible: list[AgentRunRead] = [] for run in runs: payloads = payloads_by_run_id.get(run.run_id) if payloads is None: continue route_json, ontology_json = payloads if cls._tenant_scope_from_payloads(route_json, ontology_json) != current_tenant_id: continue if cls.is_finance_dashboard_snapshot(run) and not cls._can_read_finance_snapshot_scope( cls._finance_scope_from_payloads(route_json, ontology_json), current_user, ): continue visible.append(run) return visible @classmethod def _run_scope_payloads( cls, db: Session, run_ids: list[str], ) -> dict[str, tuple[object, object]]: if not run_ids: return {} rows = db.execute( select(AgentRun.run_id, AgentRun.route_json, AgentRun.ontology_json).where( AgentRun.run_id.in_(run_ids) ) ).all() return { str(run_id): (route_json, ontology_json) for run_id, route_json, ontology_json in rows } @classmethod def require_detail_read( cls, run: AgentRunRead, current_user: CurrentUserContext, ) -> None: current_tenant_id = cls.require_current_tenant_id(current_user) if cls._tenant_scope_from_payloads(run.route_json, run.ontology_json) != current_tenant_id: cls._raise_not_found() if not cls.is_finance_dashboard_snapshot(run): return run_scope = cls._finance_scope_from_payloads(run.route_json, run.ontology_json) expected_scope = ( resolve_finance_dashboard_data_scope(current_tenant_id) if current_tenant_id else None ) if ( current_tenant_id is None or run_scope is None or run_scope != (current_tenant_id, expected_scope) ): cls._raise_not_found() FinanceDashboardAccessPolicy.require_read(current_user) @classmethod def is_finance_dashboard_snapshot(cls, run: AgentRunRead) -> bool: route = run.route_json if isinstance(run.route_json, dict) else {} return any( str(route.get(key) or "").strip() == FINANCE_DASHBOARD_TASK_TYPE for key in ("task_type", "job_type") ) @classmethod def _can_read_finance_snapshot_scope( cls, run_scope: tuple[str, str] | None, current_user: CurrentUserContext, ) -> bool: current_tenant_id = cls._normalized_tenant_id(current_user.tenant_id) expected_scope = ( resolve_finance_dashboard_data_scope(current_tenant_id) if current_tenant_id is not None else None ) return bool( current_tenant_id and run_scope and run_scope == (current_tenant_id, expected_scope) and FinanceDashboardAccessPolicy.can_read(current_user) ) @classmethod def _finance_scope_from_payloads( cls, *payloads: object, ) -> tuple[str, str] | None: tenant_ids: list[str] = [] data_scopes: list[str] = [] for payload in payloads: if not isinstance(payload, dict): return None tenant_id = cls._normalized_tenant_id(payload.get("tenant_id")) data_scope = str(payload.get("data_scope") or "").strip() if tenant_id is None or not data_scope: return None tenant_ids.append(tenant_id) data_scopes.append(data_scope) if not tenant_ids or len(set(tenant_ids)) != 1 or len(set(data_scopes)) != 1: return None return tenant_ids[0], data_scopes[0] @classmethod def _tenant_scope_from_payloads( cls, *payloads: object, ) -> str | None: tenant_ids: list[str] = [] for payload in payloads: if not isinstance(payload, dict) or "tenant_id" not in payload: return None tenant_id = cls._normalized_tenant_id(payload.get("tenant_id")) if tenant_id is None: return None tenant_ids.append(tenant_id) if len(tenant_ids) != len(payloads) or len(set(tenant_ids)) != 1: return None return tenant_ids[0] @classmethod def require_current_tenant_id(cls, current_user: CurrentUserContext) -> str: tenant_id = cls._normalized_tenant_id(current_user.tenant_id) if tenant_id is None: cls._raise_not_found() return tenant_id @staticmethod def _raise_not_found() -> None: # 统一按不存在处理,避免 run_id 或作用域标记成为租户探针。 raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Run not found", ) @staticmethod def _normalized_tenant_id(value: object) -> str | None: normalized = str(value or "").strip() return normalized or None