# 车辆数据中台客户 Scope API 覆盖矩阵

更新时间：2026-07-14

状态：安全审计与实施设计；尚未开放客户主体。

## 1. 当前结论

车辆中台当前的 Bearer Token 只表达 `viewer/operator/admin` 功能角色，不表达客户或车辆数据范围。
因此，即使 OneOS 已能登录客户，也不能直接把客户 token 映射成 `viewer`：这会让客户读取全量车辆、轨迹、
告警、统计及其他人的导出文件。

2026-07-14 已只读核验 ECS 当前运行状态：

- `AUTH_MODE=enforce`；
- 使用 `AUTH_TOKENS_JSON`，未使用单一 `AUTH_TOKEN`；
- `/opt/lingniu-vehicle-platform/env/platform.env` 权限为 `0600 root:root`；
- `lingniu-vehicle-platform` 服务已启用且正在运行。

该配置能阻止匿名访问普通平台 API，但不能提供客户级隔离。

## 2. 独立发现：高德服务端接口绕过平台鉴权

`app.NewServer` 当前先把平台 API 包在 `withAPIAuth` 中，之后又在外层注册
`withAMapReverseGeocodeAPI`。所以 `/api/map/reverse-geocode` 在进入鉴权中间件前就会被处理；只要配置了
`AMAP_API_KEY`，匿名请求即可消耗服务端高德额度。

整改要求：

- 将逆地理编码路由放回鉴权之后；
- 对客户和内部主体分别限流，并限制经纬度格式和并发；
- 不记录完整 access token；
- `app-config.js` 可继续公开，但只能暴露高德 Web JS 公钥和代理路径，不暴露服务端 API Key；
- 高德安全代理因浏览器 SDK 需要可访问，应在边缘层配置域名来源、速率和响应缓存，不能把它视为客户身份接口。

## 3. 主体与权限模型

`Principal` 至少扩展为：

```text
name
role                  # 仅内部功能角色
subject_type          # employee | service | customer
subject_id            # OneOS sys_user.user_id
customer_id           # 仅 customer 必填
tenant_id
identity_version
capabilities          # vehicle:read, history:read, export:create ...
```

约束：

- 内部静态 token 只能生成 `employee/service` 主体；
- 客户主体只能来自 OneOS 签名、短时且 `aud=vehicle-data-platform` 的声明；
- 浏览器 Query、Body、Cookie 或普通转发头中的 `customerId` 均无效；
- 客户权限不能通过 `roleRank(viewer)` 复用内部 viewer 权限，需要独立 capability 判断；
- 未知 `subject_type`、缺失 customer ID、签名异常或身份版本失效全部拒绝。

## 4. QueryScope 机制

所有 Store 查询显式接收 `QueryScope`，避免新增方法时忘记从 `context.Context` 读取 Scope：

```text
mode: unrestricted | customer_current | customer_period
customer_id
active_version
valid_from / valid_to  # 历史持有期查询
```

`unrestricted` 只能由已验证的内部 employee/service 主体创建。空客户 Scope 表示“零车辆”，绝不能解释为不限制。

MySQL 当前数据查询应 JOIN：

```text
business_scope_state(id=1)
business_customer_vehicle_scope(
  source_version = active_version,
  customer_id = principal.customer_id,
  vin = 业务表.vin
)
```

同时要求：

- `last_success_at` 距当前时间不超过 10 分钟；
- active version 存在且快照计数完整；
- 快照缺失或过期返回 `503 BUSINESS_SCOPE_UNAVAILABLE`，而不是空列表或全量数据；
- 快照新鲜但该客户确实没有车辆时返回空列表；
- 单车越权统一返回 404，避免泄露 VIN 是否存在。

TDengine 无法直接 JOIN MySQL Scope：单车查询先在 MySQL 做授权判断；多车查询从同一 active version 读取授权
VIN 集合，设置数量上限后再生成参数化过滤。不能先从 TDengine 取全量再由前端过滤。

## 5. API 覆盖矩阵

### 5.1 客户首期允许，但必须按当前 Scope 过滤

| API 组 | 代表路由 | 必须实施的控制 |
| --- | --- | --- |
| 全局监控 | `/api/v2/monitor/summary`、`/map` | 车辆、点位、聚合、告警数和所有汇总均从同一客户 Scope 计算；禁止混用全局 DashboardSummary |
| 车辆列表与解析 | `/api/vehicles`、`/resolve`、`/detail` | VIN、车牌、终端号解析必须先限定 Scope；越权关键字表现为不存在 |
| 最新状态 | `/api/realtime/vehicles`、`/locations` | 列表、total、分页和筛选均在 SQL 层过滤 |
| 车辆只读档案 | `GET /api/v2/vehicles/{vin}/profile` | 先校验 VIN；只返回门户需要的非敏感字段 |
| 最新遥测 | `/api/v2/vehicles/{vin}/telemetry/latest` | 路径 VIN 校验 Scope；原始协议帧字段按白名单输出 |
| 轨迹 | `/api/v2/tracks` | 车牌/VIN/终端解析、轨迹点、停留点和统计都使用同一授权 VIN |
| 历史查询 | `/api/history/locations`、`/api/v2/history/query`、`/series` | 首期只允许当前车辆且 eventAt 不早于 `scope_start_at`；不能读取交付前数据 |
| 里程 | `/api/mileage/summary`、`/daily`、`/api/v2/statistics/mileage` | 列表与汇总按 VIN 和授权时间过滤，total 不能泄露全局数量 |
| 在线统计 | `/api/statistics/online-summary`、`/online-vehicles` | 分母、在线数、离线数、趋势和明细均限定客户车辆 |
| 告警只读 | `/api/v2/alerts/summary`、`/events`、`/events/{id}` | 列表 JOIN event.vin Scope；详情按 ID+Scope 查询，越权返回 404 |
| 指标元数据 | `/api/v2/metrics`、`/api/v2/history/metrics` | 可返回全局字段定义，但过滤内部专用/敏感指标 |
| 逆地理编码 | `/api/map/reverse-geocode` | 必须鉴权和限流；不依赖客户参数，不返回其他车辆信息 |

### 5.2 客户首期明确禁止

| API 组 | 路由 | 原因 |
| --- | --- | --- |
| 平台总览 | `/api/dashboard/summary` | 包含全局车辆、帧量、Kafka/服务健康信息 |
| 接入诊断 | `/api/vehicles/coverage*`、`/api/vehicle-service*` | 包含协议覆盖、终端、原始帧和数据质量诊断 |
| 原始帧 | `/api/history/raw-frames*` | 包含协议证据和内部字段；首期使用白名单遥测/历史 API 代替 |
| 质量中心 | `/api/quality/*`、旧 `/api/alert-events/*` 别名 | 平台级质量问题，不是客户业务告警 |
| 运维 | `/api/ops/*` | 数据源、容量、Kafka、Redis 和服务健康仅内部可见 |
| 接入管理 | `/api/v2/access/*` | 身份绑定、阈值和未解析终端均为内部运维能力 |
| 车辆档案写入 | `PUT /api/v2/vehicles/{vin}/profile`、`/vehicle-profiles/sync` | 只允许内部管理员/同步服务 |
| 告警规则/处置 | `/api/v2/alerts/rules*`、`events/{id}/actions` | 首期客户只读，不能修改平台规则或全局事件状态 |
| 平台通知 | `/api/v2/alerts/notifications*` | 当前通知没有客户 owner，读取与已读操作均可能跨客户 |

禁止应在服务端 capability 层返回 403，不能只隐藏前端菜单。

### 5.3 导出必须单独重构后才能开放

当前导出实现有四个 P0 问题：

1. `CreateHistoryExport` 没有 `context`，创建时无法固化主体；
2. `HistoryExportJob` 没有 owner/customer/scope version；
3. `ListHistoryExports` 返回全局任务；
4. `HistoryExportFile(id)` 只校验 ID 和完成状态，知道 ID 即可下载。

整改要求：

- `Create/List/Download` 全部接收 context 和 Principal；
- Job 固化 `owner_subject_id`、`customer_id`、`scope_version`、已解析 VIN、时间范围和审计 request ID；
- 创建任务时校验每个 VIN，异步执行前再次校验；
- 列表只返回当前 owner；下载同时校验 owner 和当前授权，越权返回 404；
- 客户停用、车辆授权撤销或 Scope 失效时停止未完成任务并拒绝下载；
- 文件名和 CSV 元数据不得包含其他客户信息；文件按 owner 隔离目录并设置 `0640`；
- 定期清理过期文件与 jobs 记录。

在上述改造完成前，客户主体不授予 `export:create` 或 `export:download`。

## 6. 当前 Scope 与历史 Scope 的边界

已实现的 `business_customer_vehicle_scope` 只保存当前有效车辆。它足以保护实时监控，但不能完整实现“客户还车后
仍可查看持有期间历史”。

首期安全策略：

- 只允许查询当前仍在 Scope 中的车辆；
- 历史起点不得早于当前记录的 `scope_start_at`；
- 车辆还车并从当前 Scope 移除后，历史访问也随即关闭。

若产品确认需要持有期历史，再新增版本化区间投影：

```text
customer_id, vin, contract_id, valid_from, valid_to, source_version
```

历史查询必须满足 VIN 和 `valid_from <= event_at < valid_to`。不能仅凭“客户曾经拥有过这辆车”开放该 VIN 的全部历史。

## 7. 必须覆盖的越权测试

准备客户 A、客户 B、内部 viewer、内部 operator、内部 admin 和 service 六类主体，至少覆盖：

1. A 在列表、关键字解析、路径 VIN、POST body、分页游标中请求 B 的车辆；
2. A 直接访问 B 的告警事件 ID、导出 ID 和下载 URL；
3. A 使用车牌或终端号绕过 VIN 校验；
4. A 的空 Scope 不返回全量；Scope 缺失/过期返回 503；
5. 快照切换期间所有查询只使用一个 active version，不混合新旧结果；
6. total、summary、地图聚合数量和在线率不泄露全局或 B 的数量；
7. A 只能读取交付后的历史点，不能读取交付前记录；
8. 车辆从 A 还车并交付给 B 后，A 的实时访问立即 404，B 只能读取自身授权时间段；
9. 客户主体访问运维、接入、质量、写档案、规则和处置接口均为 403；
10. 匿名逆地理编码失败；认证请求达到限额后返回 429；
11. 客户 token 的 customer ID 不能被 Query/Header/Body 覆盖；
12. 服务重启后导出 owner、Scope 和下载授权仍保持，不能退化成全局任务。

## 8. 推荐实施顺序

1. 扩展 Principal 和 OneOS JWT 校验，但暂不开放客户路由；
2. 引入强类型 QueryScope，先改车辆解析与单车详情；
3. 改所有列表、汇总、地图、统计和 TDengine 查询；
4. 改告警详情 IDOR、逆地理编码鉴权限流；
5. 重构导出 owner 与异步 Scope；
6. 完成双客户自动化测试和生产影子查询；
7. 最后为测试客户启用 capabilities，默认保持客户入口关闭。
