feat: harden content asset access

This commit is contained in:
Codex
2026-06-29 18:25:47 +08:00
parent ee578af1f6
commit 79d0d786a0
13 changed files with 762 additions and 67 deletions

View File

@@ -91,14 +91,16 @@
| --- | --- | --- |
| 内容资源台账 | 可联调 | `content_assets` |
| 租户后台资源维护 | 可联调 | `/api/tenant-content/assets` |
| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets``/api/catalog/assets/download` |
| 学生端资源列表/下载签名 | 可联调 | `/api/catalog/assets``/api/catalog/assets/download`;学生端锁定资源使用短 TTL访问事件写入 `content_asset_access_events` |
| 阿里云 OSS 签名 | 可联调 | `aliyun_oss` provider |
| 腾讯 COS 签名 | 可联调 | `tencent_cos` provider |
| Supabase Storage 签名 | 可联调 | `supabase_storage` provider |
| 上传后对象校验 | 可联调 | `/api/tenant-content/assets/confirm-upload`;托管对象必须 verified 后才能发布/下载 |
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview``/api/tenant-content/assets/sign-preview`;使用 inline 短期签名 |
| PDF/图片预览签名 | 可联调 | `/api/catalog/assets/preview``/api/tenant-content/assets/sign-preview`;使用 inline 短期签名,学生预览默认短 TTL |
| 托管资源 worker 复检 | 可联调 | `apps/worker --job assets` 定期复检 pending/verified 对象元数据;异常资源会标记 failed 并从 active 退回 draft写入审计和 `security_flags` |
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补 CDN 防盗链、动态水印、安全扫描和对象生命周期策略 |
| 资源访问审计 | 可联调 | `content_asset_access_events` + `GET /api/tenant-content/assets/access-events`;记录上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、TTL、签名模式、IP 和 UA |
| CDN 访问边界 | 可联调 | `members/svip/private` 外部 CDN URL 默认拒绝,必须显式 `metadata.providerManagedAccess=true``cdnAccessMode=signed_by_provider`;视频绑定资源也复用该规则 |
| 深度防盗链/水印/杀毒 | 待补齐 | 商用上线前继续补动态水印、安全扫描、CDN 刷新和对象生命周期策略 |
## 订单、会员、营销

View File

@@ -49,7 +49,9 @@
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
- 已补上传后对象确认接口、托管对象发布前 verified 校验、PDF/图片 inline 预览签名。
- 已补 assets worker 复检,异常托管对象会自动下架并记录审计。
- 继续补视频深度防盗链、动态水印、杀毒扫描、CDN 刷新和对象生命周期策略
- 已补资源访问事件 `content_asset_access_events`,覆盖上传签名/确认、学生下载/预览、后台下载/预览的 granted/denied、短 TTL、签名模式、IP 和 UA
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
- 继续补动态水印、杀毒扫描、CDN 刷新和对象生命周期策略。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
3. 真实导入 dry-run
@@ -222,5 +224,5 @@
3. 补平台后台增强:租户详情/编辑、平台审计报表、自动计费、账单批量操作和更细平台权限点。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,做完整 dry-run 迁移。
6. 并行补对象存储、真实登录、完整资金流水对账、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
6. 并行补真实登录、完整资金流水对账、对象存储杀毒/水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
7. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。

View File

@@ -52,6 +52,12 @@ GET /api/catalog/assets/preview?assetId=...
POST /api/videos/play
```
后台查看资源访问事件:
```text
GET /api/tenant-content/assets/access-events?assetId=...
```
后台管理员下载:
```text
@@ -86,8 +92,9 @@ npm --workspace @tiku-saas/worker run exports:once
4. 调用 `POST /api/tenant-content/assets/confirm-upload`由后端读取对象元数据并比对大小、MIME、SHA-256。
5. 校验通过且 `publish=true` 时,后端将资源置为 `status=active``uploadStatus=verified`
6. 学生端只能下载或预览 `active + verified` 的托管对象资源。
7. 生产环境定时运行 assets worker复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据
8. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true``audit_logs`
7. 学生端和后台管理员下载/预览都会写入 `content_asset_access_events`,包含 `assetId/userId/accessType/result/expiresInSec/signatureMode/ip/userAgent` 等审计字段
8. 生产环境定时运行 assets worker复检 `pending/verified` 托管对象的大小、MIME、SHA-256 等元数据
9. 如果复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致worker 会把资源置为 `uploadStatus=failed`,并将 `active` 资源退回 `draft`,同时写入 `security_flags.assetRecheckFailed=true``audit_logs`
## 题库导出生成资源
@@ -131,11 +138,45 @@ npm --workspace @tiku-saas/worker run exports:once
- 上传会校验 MIME 类型和文件大小。
- 托管对象资源未确认前不能发布为 `active`,学生端不可下载。
- PDF/图片预览使用 `inline` 签名,不等同于长期公开 URL。
- 下载和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
- 下载、预览和视频播放必须先经过 API 权限判断,再下发短期签名 URL。
- 学生端 `preview`、锁定资料下载、视频和资料包默认使用更短 TTL。当前策略是学生 inline 预览、`members/svip/private` 资料、`video/package` 资源最多 300 秒;租户后台预览最多 3600 秒,后台下载最多 86400 秒。
- `visibility in ('members','svip','private')` 的外部 CDN/直链资源默认会被拒绝,除非资源 `metadata.providerManagedAccess=true``metadata.cdnAccessMode='signed_by_provider'`。商用建议这类资源优先登记为 `objectKey`,由 API 生成 OSS/COS/Supabase Storage 私有签名 URL。
- 每次上传签名、上传确认、学生下载/预览、后台下载/预览都会写入 `content_asset_access_events`。授权失败也会记录 `result=denied``denyCode`,用于租户后台排查资源访问问题。
- 云厂商 AccessKey、SecretKey、Service Role Key 只存在服务端环境变量,不返回前端。
- `content_assets` 是资源唯一台账,前端不得绕过台账直接访问私有 bucket。
- 前端不能把 `uploadStatus=failed``status=draft` 的资源继续展示为可下载;列表仍返回时应展示“资料处理中”或“资源异常已下架”,真正下载/预览会被后端拒绝。
## 访问事件字段
资源访问事件表:
```text
public.content_asset_access_events
```
关键字段:
| 字段 | 说明 |
| --- | --- |
| `tenant_id` | 租户隔离维度 |
| `asset_id` | 资源 ID上传签名阶段可能为空 |
| `user_id` | 学生或后台操作者 |
| `actor_role` | `anonymous/student/tenant_content_editor/tenant_admin/system` |
| `access_type` | `download/preview/admin_download/admin_preview/upload_sign/upload_confirm` |
| `result` | `granted``denied` |
| `deny_code` | 拒绝原因,例如 `ASSET_CDN_ACCESS_NOT_ALLOWED` |
| `expires_in_sec` | 下发签名有效期 |
| `signature_mode` | `local-placeholder``supabase-storage-signed-url``aliyun-oss-signature-url-v1``tencent-cos-signature-url-v5``public-or-provider-managed` |
| `metadata` | 签名摘要、对象位置、文件名等排查信息;不保存云厂商密钥 |
后台接口:
```text
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
```
前端只用于后台审计和排查,不要把 `content_asset_access_events` 当成学生端下载列表来源。
## 环境变量
通用:
@@ -198,6 +239,6 @@ WORKER_ASSET_REQUEST_TIMEOUT_MS=10000
## 官方依据
- 阿里云 OSS Node.js SDK 支持通过 `signatureUrl` 为上传/下载生成带过期时间的签名 URL并可通过对象 HEAD 读取元数据
- 腾讯云 COS XML API V5 签名由 `q-sign-algorithm``q-ak``q-sign-time``q-key-time``q-header-list``q-url-param-list``q-signature` 等字段组成,可用于预签名 URL 和 HEAD Object
- Supabase Storage 提供 `createSignedUploadUrl``createSignedUrl`,分别用于签名上传和签名下载;私有 bucket 仍应配合 RLS、服务端权限控制和资源台账
- Supabase Storage JavaScript `createSignedUrl` 用于为文件创建固定有效期的签名 URL并要求对象具备 `select` 权限;本项目由服务端集中处理权限与签名
- 阿里云 OSS 官方文档建议服务端生成 signed URL 后让客户端直传对象,也说明私有对象可通过 presigned URL 在有效期内授权下载或预览
- 腾讯云 COS 文档给出的签名 URL 格式包含 `q-sign-algorithm``q-ak``q-sign-time``q-key-time``q-header-list``q-url-param-list``q-signature` 等字段;本项目服务端生成签名,不把 SecretKey 下发前端

View File

@@ -270,6 +270,37 @@ tenant:<tenantId>:theme
| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks``POST /api/tenant-content/public-question-banks/adopt``POST /api/tenant-content/public-question-banks/sync``GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...``POST /api/tenant-content/public-question-banks/conflicts/resolve``POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` |
| 题库导出 | `POST /api/tenant-content/exports/questions``GET /api/tenant-content/exports/jobs` |
## 资料、PDF 和视频资源契约
前端必须把 `content_assets` 当成资源唯一台账。学生端资料、PDF 预览和题目视频播放都不能直接拼接私有 OSS/COS/Supabase Storage URL也不能把后台配置的 `cdnUrl` 持久缓存成长期可访问地址。
学生端资料流程:
1. 列表页调用 `GET /api/catalog/assets`,只展示后端返回的 active 资源。
2. 预览 PDF/图片时调用 `GET /api/catalog/assets/preview?assetId=...`
3. 下载资料时调用 `GET /api/catalog/assets/download?assetId=...`
4. 使用响应里的 `preview.url``download.url` 立即打开;不要写入本地长期缓存。
签名有效期规则:
- 学生 inline 预览、SVIP/会员资料、视频和资料包通常只有 300 秒左右有效期。
- 后台预览有效期也不是永久 URL租户后台应在用户点击时重新请求签名。
- 响应里的 `expiresInSec/expiresAt/signatureMode` 只用于 UI 提示和排查,不要自行延长有效期。
锁定资源 CDN 规则:
- `visibility=members/svip/private` 的外部 `cdnUrl` 默认会被后端拒绝,返回 `ASSET_CDN_ACCESS_NOT_ALLOWED`
- 只有后台明确登记 `metadata.providerManagedAccess=true``metadata.cdnAccessMode='signed_by_provider'`,后端才允许把外部 URL 作为 provider-managed 资源返回。
- 商用环境更推荐把锁定资料登记为 `objectKey`,由后端生成 OSS/COS/Supabase Storage 私有签名 URL。
租户后台排查:
```text
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
```
该接口返回资源访问事件,包括学生下载、学生预览、后台下载、后台预览、上传签名、上传确认以及 denied 原因。租户后台可以在资源详情页增加“访问记录/异常记录”面板。
## 练习访问控制契约
前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules``content_nodes.accessRules``question_collections.accessRules``practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。