feat(platform): close AI expense value loop
Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
201
server/src/app/services/agent_run_access_policy.py
Normal file
201
server/src/app/services/agent_run_access_policy.py
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
Reference in New Issue
Block a user