下载接口不代理对象字节,而是在查到 FileAsset、通过通用权限和 Owner Policy 后,签发一个短期预签名 URL。这个模式降低 API 带宽开销,但也把一次授权决策转化为直到 URL 过期都有效的持有者能力。
1. 下载决策链
通用授权和 Owner Policy 是两层不同门禁。只有应用授权成功并不代表可以下载;反过来,即使是 Owner,也必须先通过生成端点的认证与 View 决策。
2. Owner Policy 精确规则
| 规则 | 成功条件 |
|---|---|
| Tenant | asset TenantId 与 AccessContext TenantId 大小写敏感相等 |
| 状态 | 必须是 Finalized |
| Tenant-public | Visibility 不区分大小写等于 public |
| User owner | OwnerType=user 且 OwnerId=当前 UserId |
| App owner | OwnerType=app 且 OwnerId=当前 ClientId |
| 全局提升 | 权限集合包含 files.asset.read.all |
| 类型提升 | 权限集合包含 files.{OwnerType}.read |
files.asset.read.all 和动态 files.{OwnerType}.read 不在 FilePermissions 的治理目录四项中。若角色管理界面只能分配目录权限,就无法形成这两条提升路径;上线前必须校准 Catalog 与 Policy。
3. Tenant 上下文细节
读取由 IFileAssetReadModelStore 的 Query Shape 执行器完成,底层聚合是租户表。Owner Policy 的 AccessContext 却使用 summary.TenantId 作为 TenantId,而不是当前用户 TenantId:
var summary = findResult.GetValueOrThrow();var user = currentUser.User;
var accessContext = new FileAssetAccessContext( summary.TenantId, // 来自已读取资产,而不是 actor/effective tenant user.UserId?.ToString(), user.ClientId, user.Permissions);因此 Policy 内部的 Tenant 比较必然成功,真正的隔离完全依赖 ReadModel Store 的租户谓词。普通查询路径通常安全,但 Policy 本身不能作为跨租户第二道防线。这是比旧文档更重要的源码事实。
4. 请求与响应
GET /api/files/019c64e67fc87db8b7ca748f5442f91a/download HTTP/1.1Authorization: Bearer eyJhbGciOiJSUzI1NiJ9{ "storageKey": "tenants/tenant-a/019c64e67fc87db8b7ca748f5442f91a", "contentType": "application/pdf", "fileName": "invoice-2026-07.pdf", "presignedUrl": "https://storage.example/presigned-get-token", "expiresAt": "2026-07-15T12:05:00Z"}当前 DTO 暴露 StorageKey。Key 不是凭据,但会泄露内部命名、租户标识和存储拓扑,不是客户端下载所必需;生产合同应考虑移除。
5. 五分钟语义并非所有 Provider 一致
Handler 向 IFileStorage 请求五分钟 TTL,并把 clock.UtcNow.AddMinutes(5) 写入响应。
- S3-compatible 使用请求 TTL 计算 URL 过期,二者一致;
- Connector
GenerateDownloadUrlArgs没有请求级 TTL,Adapter 忽略五分钟参数; - Connector 也不传递 FileName,只传 ContentType;
- Local 与 Unavailable 抛
NotSupportedException,Handler 返回File.DownloadAccessUnavailable。
因此 ExpiresAt 在连接器路径只是 Handler 的期望,不一定是 URL 的真实期限。
6. 业务模块不要转发稳定 URL
app.MapGet("/api/tickets/{ticketId}/attachments/{attachmentId}", async ( string ticketId, string attachmentId, ITicketAttachmentStore attachments, ISender sender, CancellationToken cancellationToken) => { // 先用 Ticket 自己的授权规则验证业务对象访问。 var attachment = await attachments.GetAuthorizedAsync( ticketId, attachmentId, cancellationToken);
if (attachment.IsFailure) return attachment.ToProblemResult();
// 再让 Files 执行资产状态、Owner 和预签名决策。 var access = await sender.Send( new GetFileDownloadAccessQuery(attachment.Value.FileId), cancellationToken);
// 返回 302 可减少 URL 暴露在业务响应模型中的时间。 return access.IsSuccess ? Results.Redirect(access.Value.PresignedUrl) : access.ToProblemResult(); });这是基于当前消息合同的消费示例。真实模块若与 Files 处于同一 Host,应保留双重授权:Ticket 决定“能否看这张工单”,Files 决定“这份资产能否签发”。
7. 预签名 URL 的安全性质
预签名 URL 是 Bearer Capability。拿到它的人在过期前通常不再经过应用权限系统:
- 不写入可搜索日志、错误跟踪 Breadcrumb 或 Analytics;
- 页面使用
Referrer-Policy: no-referrer; - URL 只通过 HTTPS 返回;
- TTL 按内容敏感度缩短,不用一个默认值覆盖全部容器;
- 高敏文件使用一次性下载票据或 API 代理,而不是可转发 URL;
- 权限撤销不能立即撤销已经签发的普通 S3 URL,需短 TTL、Key 轮换或代理层;
- CDN 缓存键与 Cache-Control 必须避免跨用户复用。
8. Content-Disposition 与文件名
S3 Adapter 会生成:
Content-Disposition: attachment; filename="invoice-2026-07.pdf"当前 SanitizeFileName 只删除 "、CR 与 LF。国际化文件名、路径分隔符、控制字符、超长名字和同形字符没有集中策略。生产实现建议同时提供 ASCII fallback 与 RFC 5987 filename*,并在 UI 中独立转义。
Connector Adapter 忽略 fileName 参数,实际下载名由 Provider 决定。这必须进入 Provider 契约测试。
9. 错误与信息泄露
| 失败 | 稳定码 | 对外建议 |
|---|---|---|
| 读模型查无资产 | File.NotFound | 不透露跨租户是否存在 |
| 非 Finalized | File.NotFinalized | 409;Owner 可显示处理中 |
| Policy Tenant 不匹配 | File.TenantMismatch | 403;内部审计跨租户尝试 |
| 非 Owner/无提升权限 | File.AccessDenied | 403,不暴露 OwnerId |
| Provider 不支持 presign | File.DownloadAccessUnavailable | 503/稳定失败,不返回假 URL |
当前 Handler 没有专属 Security Audit,也没有下载成功/拒绝指标;只有通用 Activity 审计路径。预签名后的真实 GET 发生在对象存储,需要存储访问日志或 CDN 日志补齐证据。
10. 测试 Owner 矩阵
[Theory][InlineData("tenant-a", "Finalized", "private", "7", null, true)][InlineData("tenant-a", "Uploaded", "private", "7", null, false)][InlineData("tenant-b", "Finalized", "private", "7", null, false)][InlineData("tenant-a", "Finalized", "private", "8", null, false)][InlineData("tenant-a", "Finalized", "public", "8", null, true)]public void Download_policy_should_close_every_boundary( string tenant, string status, string visibility, string userId, string? elevatedPermission, bool expected){ // 固定资产状态、可见性和 Owner,避免测试数据工厂隐式放行。 var asset = AssetSummary(tenant, status, visibility, ownerId: "7"); var permissions = elevatedPermission is null ? [] : new[] { elevatedPermission }; var context = new FileAssetAccessContext(tenant, userId, null, permissions);
// 每一行同时验证成功/拒绝,新增状态或可见性时必须补充决策。 FileAssetOwnerPolicy.EnsureCanDownload(asset, context) .IsSuccess.ShouldBe(expected);}示例展示目标矩阵形状;实际测试还要区分 user/app、动态 OwnerType 权限大小写、空主体、跨租户 ReadModel 和 operate-as。
11. 生产改进优先级
- AccessContext 使用 Actor/Efffective Tenant,而不是 asset Tenant 自证。
- 把 OwnerType 与 Visibility 收口为注册目录或值对象。
- 统一 Catalog 中的
read.all与动态类型权限。 - 从响应移除 StorageKey。
- Provider 返回真实
ExpiresAt,不要由 Handler 猜测。 - 给预签名签发加入速率限制、专属 Security Audit 与指标。
- 高敏文件增加一次性票据、下载次数或审批策略。
- 用对象存储访问日志串联 CorrelationId,但不把完整 URL 作为标签。
12. 源码与验证
# 运行现有 Owner Policy 与下载 Handler 测试。dotnet test tests/BitzOrcas.Unit.Tests \ --filter FullyQualifiedName~FileAssetPolicyTestsdotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~FileDownloadAccess
# 对比治理目录与 Policy 内硬编码提升权限。rg -n "files\.asset\.read\.all|files\.\{asset.OwnerType\}\.read|files\.file\.view" \ src/Platform/Files -g '*.cs'
# 核对不同 Provider 是否兑现 TTL 与文件名。rg -n "GeneratePresignedDownloadUrlAsync|GenerateDownloadUrlArgs|Expires =" \ src/Platform/Files src/Framework -g '*.cs'