Skip to content
bitzorcas
中EN

Concept

Files download authorization and owner policy

Generic authorization, tenant isolation, owner policy, tenant-public semantics, presigned URLs, and calling-module integration.

Last updated

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

noyesnoyesyesnoyesnoyesno

GET /api/files/{id}/download

Generic View authorization
files.file.view

IFileAssetReadModelStore
tenant-filtered summary

asset tenant equals context tenant?

Status is Finalized?

Visibility is public?

user/app owner?

read.all or ownerType.read?

IFileStorage presigned GET

Stable denial/conflict

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

RuleSuccess condition
TenantAsset and access-context TenantId match case-sensitively
StateStatus is Finalized
Tenant-publicVisibility equals public, case-insensitive
User ownerOwnerType=user and OwnerId=current UserId
App ownerOwnerType=app and OwnerId=current ClientId
Global elevationPermissions contain files.asset.read.all
Type elevationPermissions 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:

Current access-context construction
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

Request download access
GET /api/files/019c64e67fc87db8b7ca748f5442f91a/download HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9
FileDownloadAccess example
{
"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 GenerateDownloadUrlArgs has no request TTL, so the adapter ignores five minutes;
  • the connector also ignores FileName and passes ContentType only;
  • Local and Unavailable throw NotSupportedException, mapped to File.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

Ticket attachment endpoint delegating to Files
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

FailureStable codeExternal handling
Summary not foundFile.NotFoundDo not reveal cross-tenant existence
Not FinalizedFile.NotFinalized409; owner UI may show processing
Policy tenant mismatchFile.TenantMismatch403 and internal security audit
Non-owner/no elevationFile.AccessDenied403 without OwnerId disclosure
Provider cannot presignFile.DownloadAccessUnavailableStable 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

Table-driven tenant, state, and owner policy
[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

  1. Build AccessContext from actor/effective tenant instead of the asset self-asserting its tenant.
  2. Replace free-form OwnerType and Visibility with registries or value objects.
  3. Align read.all and dynamic-type permissions with the catalog.
  4. Remove StorageKey from the response.
  5. Make providers return actual expiry instead of letting the handler guess.
  6. Add issuance rate limits, Security audit, and metrics.
  7. Add one-time grants, count limits, or approval for sensitive objects.
  8. Correlate storage access logs without using full URLs as labels.

12. Source and verification

Terminal window
# Run existing owner-policy and download-handler tests.
dotnet test tests/BitzOrcas.Unit.Tests \
--filter FullyQualifiedName~FileAssetPolicyTests
dotnet 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

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%