The download endpoint does not proxy object bytes. It reads a FileAsset, passes generic authorization and owner policy, then grants a short-lived presigned URL. This saves API bandwidth, but it turns one authorization decision into a bearer capability that remains valid until the URL expires.
1. Download decision chain
Generic authorization and owner policy are separate gates. A successful View decision does not grant download. Conversely, an owner still passes authentication and the generated endpoint’s View decision first.
2. Exact owner-policy rules
| Rule | Success condition |
|---|---|
| Tenant | Asset and access-context TenantId match case-sensitively |
| State | Status is Finalized |
| Tenant-public | Visibility equals public, case-insensitive |
| User owner | OwnerType=user and OwnerId=current UserId |
| App owner | OwnerType=app and OwnerId=current ClientId |
| Global elevation | Permissions contain files.asset.read.all |
| Type elevation | Permissions contain files.{OwnerType}.read |
files.asset.read.all and dynamic files.{OwnerType}.read are absent from the four FilePermissions catalog entries. A role UI backed only by that catalog cannot assign those paths. Catalog and policy must be reconciled before GA.
3. Tenant-context detail
IFileAssetReadModelStore executes the summary query over a tenant aggregate. The handler then constructs the policy context with summary.TenantId, not the current actor/effective tenant:
var summary = findResult.GetValueOrThrow();var user = currentUser.User;
var accessContext = new FileAssetAccessContext( summary.TenantId, // From the asset, not actor/effective tenant. user.UserId?.ToString(), user.ClientId, user.Permissions);The tenant comparison inside the policy therefore always succeeds. Real isolation is entirely dependent on the read-model tenant predicate. Normal queries are expected to be isolated, but the policy itself is not a second cross-tenant defense.
4. Request and response
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"}StorageKey is currently exposed. It is not a credential, but it reveals internal naming, tenant identity, and topology and is unnecessary for client download. A production contract should remove it.
5. Five-minute semantics vary by provider
The handler requests a five-minute TTL and emits clock.UtcNow.AddMinutes(5):
- S3-compatible uses the requested TTL, so the values align;
- Connector
GenerateDownloadUrlArgshas no request TTL, so the adapter ignores five minutes; - the connector also ignores FileName and passes ContentType only;
- Local and Unavailable throw
NotSupportedException, mapped toFile.DownloadAccessUnavailable.
On the connector path, ExpiresAt is only the handler’s expectation, not necessarily the URL’s real expiry.
6. A business endpoint should delegate, not cache
app.MapGet("/api/tickets/{ticketId}/attachments/{attachmentId}", async ( string ticketId, string attachmentId, ITicketAttachmentStore attachments, ISender sender, CancellationToken cancellationToken) => { // First enforce the Ticket module's business-object authorization. var attachment = await attachments.GetAuthorizedAsync( ticketId, attachmentId, cancellationToken);
if (attachment.IsFailure) return attachment.ToProblemResult();
// Then let Files enforce asset state, owner policy, and URL issuance. var access = await sender.Send( new GetFileDownloadAccessQuery(attachment.Value.FileId), cancellationToken);
// A redirect can reduce how long the URL lives in business response models. return access.IsSuccess ? Results.Redirect(access.Value.PresignedUrl) : access.ToProblemResult(); });This is a consumption example based on the current message contract. Preserve both authorization layers: Ticket decides access to the ticket; Files decides whether the asset may be granted.
7. Security properties of a presigned URL
A presigned URL is a bearer capability. A holder typically bypasses application authorization until expiry:
- never write it to searchable logs, error breadcrumbs, or analytics;
- use
Referrer-Policy: no-referrer; - return it only over HTTPS;
- vary TTL by sensitivity instead of using one global default;
- use a one-time ticket or API proxy for high-sensitivity files;
- permission revocation does not revoke an existing ordinary S3 URL immediately;
- prevent CDN cache reuse across users with correct cache keys and headers.
8. Content-Disposition and names
The S3 adapter generates:
Content-Disposition: attachment; filename="invoice-2026-07.pdf"SanitizeFileName removes only quote, CR, and LF. It has no centralized policy for international names, separators, control characters, long names, or bidi text. Production should provide an ASCII fallback plus RFC 5987 filename*, with separate UI escaping.
The connector adapter ignores fileName, so the provider controls the resulting name. This belongs in provider contract tests.
9. Errors and information disclosure
| Failure | Stable code | External handling |
|---|---|---|
| Summary not found | File.NotFound | Do not reveal cross-tenant existence |
| Not Finalized | File.NotFinalized | 409; owner UI may show processing |
| Policy tenant mismatch | File.TenantMismatch | 403 and internal security audit |
| Non-owner/no elevation | File.AccessDenied | 403 without OwnerId disclosure |
| Provider cannot presign | File.DownloadAccessUnavailable | Stable failure, never fake URL |
There is no Files-specific Security audit or grant/denial metric. The generic Activity path records the application request. The actual GET happens at storage, so storage/CDN access logs are needed for evidence.
10. Owner matrix test
[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){ // Freeze state, visibility, and owner so the fixture cannot grant access implicitly. var asset = AssetSummary(tenant, status, visibility, ownerId: "7"); var permissions = elevatedPermission is null ? [] : new[] { elevatedPermission }; var context = new FileAssetAccessContext(tenant, userId, null, permissions);
// Every row asserts grant or denial; new states and visibility values need a decision. FileAssetOwnerPolicy.EnsureCanDownload(asset, context) .IsSuccess.ShouldBe(expected);}The production matrix also needs user/app owners, dynamic OwnerType casing, empty principals, actual cross-tenant read models, and operate-as.
11. Production improvement order
- Build AccessContext from actor/effective tenant instead of the asset self-asserting its tenant.
- Replace free-form OwnerType and Visibility with registries or value objects.
- Align
read.alland dynamic-type permissions with the catalog. - Remove StorageKey from the response.
- Make providers return actual expiry instead of letting the handler guess.
- Add issuance rate limits, Security audit, and metrics.
- Add one-time grants, count limits, or approval for sensitive objects.
- Correlate storage access logs without using full URLs as labels.
12. Source and verification
# Run existing owner-policy and download-handler tests.dotnet test tests/BitzOrcas.Unit.Tests \ --filter FullyQualifiedName~FileAssetPolicyTestsdotnet test tests/BitzOrcas.Application.Tests \ --filter FullyQualifiedName~FileDownloadAccess
# Compare catalog permissions with policy-only elevated permissions.rg -n "files\.asset\.read\.all|files\.\{asset.OwnerType\}\.read|files\.file\.view" \ src/Platform/Files -g '*.cs'
# Check provider handling of TTL and file name.rg -n "GeneratePresignedDownloadUrlAsync|GenerateDownloadUrlArgs|Expires =" \ src/Platform/Files src/Framework -g '*.cs'Previous: Finalization and integrity · Next: Storage lifecycle