202 lines
7.2 KiB
Python
202 lines
7.2 KiB
Python
|
|
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
|