Skip to content
bitzorcas
中EN

Concept

Files 下载授权与 Owner Policy

讲清下载请求的通用授权、租户隔离、Owner Policy、public 语义、预签名 URL 与调用模块集成。

Last updated

下载接口不代理对象字节,而是在查到 FileAsset、通过通用权限和 Owner Policy 后,签发一个短期预签名 URL。这个模式降低 API 带宽开销,但也把一次授权决策转化为直到 URL 过期都有效的持有者能力。

1. 下载决策链

否是否是是否是否是否

GET /api/files/{id}/download

通用 View 授权
files.file.view

IFileAssetReadModelStore
按有效租户读取摘要

asset.TenantId == context.TenantId?

Status == Finalized?

Visibility == public?

user/app Owner?

read.all 或 ownerType.read?

IFileStorage 预签名 GET

稳定拒绝/冲突

通用授权和 Owner Policy 是两层不同门禁。只有应用授权成功并不代表可以下载;反过来,即使是 Owner,也必须先通过生成端点的认证与 View 决策。

2. Owner Policy 精确规则

规则成功条件
Tenantasset TenantId 与 AccessContext TenantId 大小写敏感相等
状态必须是 Finalized
Tenant-publicVisibility 不区分大小写等于 public
User ownerOwnerType=user 且 OwnerId=当前 UserId
App ownerOwnerType=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:

当前下载处理器构造 AccessContext 的方式
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.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
FileDownloadAccess 示例
{
"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

业务下载端点委托 Files 的示例
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不透露跨租户是否存在
非 FinalizedFile.NotFinalized409;Owner 可显示处理中
Policy Tenant 不匹配File.TenantMismatch403;内部审计跨租户尝试
非 Owner/无提升权限File.AccessDenied403,不暴露 OwnerId
Provider 不支持 presignFile.DownloadAccessUnavailable503/稳定失败,不返回假 URL

当前 Handler 没有专属 Security Audit,也没有下载成功/拒绝指标;只有通用 Activity 审计路径。预签名后的真实 GET 发生在对象存储,需要存储访问日志或 CDN 日志补齐证据。

10. 测试 Owner 矩阵

租户、状态与 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. 生产改进优先级

  1. AccessContext 使用 Actor/Efffective Tenant,而不是 asset Tenant 自证。
  2. 把 OwnerType 与 Visibility 收口为注册目录或值对象。
  3. 统一 Catalog 中的 read.all 与动态类型权限。
  4. 从响应移除 StorageKey。
  5. Provider 返回真实 ExpiresAt,不要由 Handler 猜测。
  6. 给预签名签发加入速率限制、专属 Security Audit 与指标。
  7. 高敏文件增加一次性票据、下载次数或审批策略。
  8. 用对象存储访问日志串联 CorrelationId,但不把完整 URL 作为标签。

12. 源码与验证

Terminal window
# 运行现有 Owner Policy 与下载 Handler 测试。
dotnet test tests/BitzOrcas.Unit.Tests \
--filter FullyQualifiedName~FileAssetPolicyTests
dotnet 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'

上一页:完成上传与完整性 · 下一页:存储组合、删除与清理

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%