Files promotes bytes in object storage into an authorized, traceable FileAsset that a business module can reference. It has server-generated storage keys, tenant-scoped aggregates, upload finalization, an owner download policy, dual-ORM persistence, and S3 presigned access. It does not yet have malware scanning, quotas, trusted server-side hashing, upload-expiry enforcement, or reclamation of deleted objects.
1. What the module solves
Object storage understands buckets, keys, and bytes. It does not understand tenant ownership, business attachment rules, authorization, or whether an order may bind an object. Files supplies that asset boundary:
FileAssetrecords the tenant, owner, original name, declared metadata, observed object facts, and lifecycle;- application use cases create direct-upload capabilities, finalize uploads, grant downloads, and delete assets;
IFileStorageisolates Local, S3-compatible, and connector providers;- consuming modules persist a
FileIdand continue to own their business attachment rules; FileAssetSummaryis an immutable event/cross-module snapshot, not a mutable aggregate.
The module does not own business attachment tables, image transformation, document previews, content extraction, versioning, or compliance retention decisions.
2. Current end-to-end path
There are two independent sources of truth: the database stores asset state, while object storage stores bytes. The current code has no atomic transaction across them, so every create, finalize, and delete operation must account for asymmetric failure.
3. HTTP use cases
| Method and route | Message | Action | Current result |
|---|---|---|---|
POST /api/files/upload-session | CreateUploadSessionCommand | Create | UploadSession with FileId, StorageKey, and URL |
POST /api/files/{fileId}/finalize | FinalizeUploadCommand | Update | FileAssetSummary |
GET /api/files/{fileId}/download | GetFileDownloadAccessQuery | View | A nominal five-minute download descriptor |
DELETE /api/files/{fileId} | DeleteFileCommand | Delete | Soft-delete and publish a deleted summary |
All four messages implement IAuthorizedRequest. The governance catalog declares files.file.create/view/update/delete. After generic View authorization, download also runs FileAssetOwnerPolicy. Delete has no owner policy, so a caller with Delete permission can delete any visible file in the effective tenant.
4. Lifecycle and the binding invariant
CanBindToBusiness() returns true only for Finalized. Object existence, an Uploaded database row, possession of a storage key, or possession of an upload URL cannot replace this domain decision.
FileAsset.Restore is an ORM materialization factory. It is not a business restore operation. There is no restore endpoint or Deleted-to-Finalized transition.
5. Trust boundaries
| Data | Source | Current trust level |
|---|---|---|
| TenantId | ICurrentUser.User.TenantId | Server context, but not EffectiveTenantId |
| OwnerId | current UserId, otherwise ClientId | Server-derived |
| OwnerType / Visibility | client | Non-empty and length checks only |
| FileName | client | Never enters StorageKey; still reaches DTOs and headers |
| Size / ContentType | create command | Baseline for finalize comparison |
| ContentHash | finalize command or object metadata | Falls back to the client when a provider has no SHA-256 |
| StorageKey | server UUID v7 | tenants/{tenantId}/{fileId} |
A file name such as ../file.pdf cannot traverse the current direct-upload key because the key is entirely server-generated. The name remains untrusted display data and needs context-specific escaping in responses, logs, and UI.
6. Persistence model
FileAsset is the unified aggregate persisted to SysFileAsset by both ORMs:
- tenant filtering and soft deletion are enabled;
- StorageKey has a global unique index;
- Tenant + Owner and Tenant + Status have non-unique indexes;
StatusNamemaps to theStatuscolumn and fails closed on unknown persisted values;- non-Pending rows require ContentHash, and Finalized requires FinalizedAt;
- StorageKey must start with
tenants/{tenantId}/or{tenantId}/.
Factories validate declared column bounds before persistence: FileId 36, OwnerType 50, OwnerId 64, Visibility 20, StorageKey 512, ContentHash/ETag 128, ContentType 100, and FileName 255.
7. Storage-provider reality matrix
| Provider | Direct I/O | Presigned upload | Metadata authority | Presigned download | Intended use |
|---|---|---|---|---|---|
UnavailableFileStorage | No | No | No | No | Fail-closed shell |
LocalFileStorage | Yes | No | Trusted length; null type/hash | No | Port tests/custom direct I/O, not the HTTP flow |
S3CompatibleFileStore | Yes | Yes | Length/type/ETag; SHA from user metadata | Yes | Current production path |
FileStoreAdapter | Provider-dependent | Provider-dependent | Common length only | Provider-dependent | Bridge exists; API composition does not call it |
The Production/Staging startup guard accepts only Minio and requires Endpoint, AccessKey, and SecretKey. Default appsettings.json selects Unavailable.
8. Highest-priority implementation gaps
- No maximum size, tenant quota, MIME allowlist, magic-byte validation, or malware scanning.
- Presigned PUT has no content-length, content-type, or checksum condition.
UploadExpiresAtis persisted but never checked by finalize.- S3 without
x-amz-meta-sha256trusts the client hash; the connector path also trusts client content type. - Repeated finalize and delete return conflicts; there is no request idempotency or result replay.
- Create signs a URL before saving the database row, leaving a capability if Save fails.
- Finalize persists and publishes inside the database path, but rollback cannot remove the external object.
- Delete only soft-deletes metadata and never invokes object-storage deletion.
- Orphan cleanup immediately selects every PendingUpload, ignores UploadExpiresAt, and never selects Deleted files.
- Download exposes StorageKey; the connector ignores requested TTL and file name, so ExpiresAt may be inaccurate.
9. Correct responsibility for a caller
For a Ticket attachment, the Ticket module still owns the binding rule:
// Read asset facts through a Files contract, not by querying SysFileAsset directly.var asset = await fileAssets.GetSummaryAsync(command.FileId, cancellationToken);
// The business owner decides whether this asset may become a ticket attachment.if (asset.IsFailure || asset.Value.Status != FileAssetStatus.Finalized) return Result.Failure(TicketErrors.AttachmentNotReady);
if (asset.Value.TenantId != currentTenant.EffectiveTenantId) return Result.Failure(TicketErrors.AttachmentTenantMismatch);
// Persist only the stable FileId, never a URL, bucket, or storage key.ticket.Attach(command.FileId, command.DisplayName);This is a consumption example based on current public contracts, not a verbatim source copy. A real caller must also close the OwnerType/OwnerId relationship. The current create route fixes OwnerId to the caller identity; it cannot register an arbitrary business object ID.
10. Reading path
- Upload sessions and direct upload
- Finalization and content integrity
- Download authorization and owner policy
- Storage composition, deletion, and cleanup
- Testing, operations, and GA gates
Related foundations: Authorization, Multitenancy, Auditing, and GDPR.
11. Minimal source review
# List all four public use cases, routes, and authorization actions.rg -n "GenerateEndpoint|AuthorizationAction" src/Platform/Files -g '*.cs'
# Search for real scanning, quota, and expiry enforcement; expect no production hit today.rg -n "Virus|Malware|Quota|UploadExpiresAt.*clock|MagicBytes" \ src/Platform/Files src/Hosts -g '*.cs'
# Prove whether deletion reaches object storage; DeleteFile currently has no IFileStorage.rg -n "DeleteAsync|DeleteFileCommandHandler" \ src/Platform/Files src/Framework/BitzOrcas.Infrastructure.SqlSugar -g '*.cs'