Architecture Decision Record
ADR-045: Managed File Storage and User Avatars
Status
Accepted (2026-07-11). Records the BR-116 amendment (ADC): avatar photos are IN scope, powered by two new framework extension points. The framework legs are implemented; each consumer provisions its own storage account and wires the upload endpoints. Revised 2026-09-07 (a decoded-pixel and dimension ceiling is checked from the image header before any frame buffer is allocated, beside the existing compressed-size cap). Revised 2026-09-12 (the extension points here carry DOCUMENT uploads as well as images; the avatar-only scope statement below is superseded by ADR-123). Revised 2026-10-01 (avatar blob deletion is a durable internal command rather than an inline storage call; see Revision below).
Context
The MAUI capability program (ADR-042) brought MediaPicker/camera within reach, and ADC amended BR-116 to include user avatar photos. That needs binary blob storage (the databases store entities, not images), untrusted-image handling (uploads are attacker-controlled bytes; EXIF metadata carries GPS coordinates, which are PII), and a client affordance that is native pick/capture on phones and a plain file input on the web.
The framework had no file-storage abstraction: anything blob-shaped would otherwise be written directly against the Azure SDK inside a module, unusable by the next consumer and untestable.
Decision
IFileStorageService(Application): upload-by-blob-name returning the public URI, plus idempotent delete. Default is an unconfigured Null implementation whose uploads fail with a clear error;AddAzureBlobFileStorage(configuration)swaps in the Azure Blob implementation when theFileStoragesection is complete (ContainerName+ eitherServiceUrifor DefaultAzureCredential/managed-identity auth, the production path, orConnectionStringfor local Azurite). The container is provisioned by infrastructure, never created by the app.IImageProcessor(Application) withImageSharpImageProcessor(Infrastructure, always registered - it has no external dependency): decode, auto-orient, center-crop to an exact square, strip ALL metadata, re-encode as JPEG. Full re-encode is the security boundary: only pixels survive, killing EXIF GPS and polyglot payloads in one move. Undecodable content is a validation failure, not an exception. ImageSharp ships under the Six Labors Split License (Apache 2.0 terms for open-source / small-revenue use, which covers this project).IMediaPickerService(UI capability, ADR-042 pattern): native photo pick/capture with the permission flow encapsulated; cancelled/denied returns null. Web heads keep the Null default (IsSupported == false) and render anInputFileinstead - an affordance switch, not a degraded path.- Avatar contract (BR-116a, applied per consumer): one avatar per user; accept jpeg/png/webp
up to 2 MB; server re-encodes to 256x256 JPEG via
IImageProcessor(client-declared content types are advisory only); blob name{userId}-{random8}.jpgin the infrastructure-provisioned public-readavatarscontainer (so the URL path readsavatars/{userId}-{random8}.jpg); replacing, removing or erasing an avatar schedules a durableDeleteAvatarBlobInternalCommand(ADR-114) rather than deleting the blob inline; the URL lives on the user entity as[Pii], nulled on anonymize with the blob delete scheduled in the same transaction; exported in the GDPR data export.
Consequences
- The avatars container is public-read by design: avatar URLs render in
<img>tags on anonymous-visible surfaces without SAS plumbing. The random blob suffix prevents enumeration; the trade-off (anyone with the URL can fetch the image) is accepted and documented in the consumer's privacy policy. - A replaced or deleted avatar has its blob deleted eventually (at-least-once, through the internal-command processor, within its retry budget), and CDN/browser caches may serve the old URL briefly; the random suffix means the new upload never reuses the old URL, so staleness is bounded by cache TTLs.
DefaultAzureCredentialin production means the storage account needs a data-plane role (Storage Blob Data Contributor) for the app identity - a bicep-level grant, not a secret.- ImageSharp joins the Infrastructure dependency set (vuln-audited like everything else); the license note above must be revisited if the project's revenue posture changes.
Revision (2026-09-07)
The 2 MB compressed upload cap bounds the bytes on the wire; it does not bound what those bytes decode to. A highly compressible image declares its dimensions in a header that costs nothing to read and expands to a frame buffer of width times height times bytes-per-pixel, which is how a small upload becomes a multi-gigabyte allocation (SEC-Common-28).
ImageSharpImageProcessor now reads the header first with Image.IdentifyAsync
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/ImageSharpImageProcessor.cs:47) and
refuses anything past MaxDecodedPixels (50,000,000, :26) or MaxDecodedDimension (20,000, :33)
as Image.TooLarge, using the predicate at :112-114. The dimension ceiling exists beside the pixel
one because a long thin image can stay under the area limit and still be pathological for the
resampler (:30). The decoded frame is re-checked after decoding, so a header that under-reports
does not get through.
Revision (2026-09-12)
This record scoped managed uploads to avatars and said so: "no other managed uploads exist". That
statement is superseded by ADR-123, which publishes speaker session
materials (decks, handouts, archives, links) through the same IFileStorageService. The framework
extension points here grew three pieces to carry it, and every one of them is document-shaped rather
than avatar-shaped:
DocumentContentSniffer(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/DocumentContentSniffer.cs:49), the sibling ofImageContentSniffer. It accepts pdf, pptx, docx, xlsx, zip, txt and md, and only when the real bytes and the file-name extension agree (Detect,:91); the client-declared content type is never consulted. It exists because the avatar boundary does NOT transfer: an image is safe because it is re-encoded pixel by pixel, and a document is stored exactly as uploaded, so the defence has to be a gate at the door. Office Open XML is decided by opening the package and looking for a[Content_Types].xmlentry BY NAME, never by reading an entry stream, with a 4096-entry bail-out for zip bombs (:153-188).BlobNames.SanitizeFileName(.../Storage/BlobNames.cs:33), because a user-supplied file name reaches a blob name and therefore a URL. Avatars never needed it: their blob name is composed entirely of a user id and a random suffix.FileUploadOptions(.../Storage/FileUploadOptions.cs:13) withAttachment(:48) andInline(:59), carrying theContent-DispositionandCache-Controlheaders stored on the blob. The options-carryingUploadAsyncoverload shipped as a default interface member so adding it broke no existing implementation, and became abstract in v1.210.0 (.../Storage/IFileStorageService.cs:36) once no implementation relied on the fallback;AzureBlobFileStorageServiceimplements it and writes both headers (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/AzureBlobFileStorageService.cs:27,:38-43).
The public-read container trade-off recorded above for avatars is taken again for documents, on the same terms and for the same reason, with unguessability supplied by a server-minted GUID inside the blob path instead of a random suffix. See ADR-123 for the aggregate, the authorization rule and the per-consumer contract (ADC BR-116b).
Revision (2026-10-01)
Avatar blob deletion no longer happens as an inline storage call. Every path that retires an avatar
schedules a DeleteAvatarBlobInternalCommand
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteAvatarBlob/DeleteAvatarBlobInternalCommand.cs:17,
named Identity.DeleteAvatarBlob.v1 at :16) through the internal-command queue of
ADR-114. Replacing an avatar schedules the previous
blob post-commit
(.../Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:156-160), removing one does the same
(.../Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:74-76), and deleting the user
schedules it inside the erasure transaction so the row commits or rolls back with the anonymized
aggregate (.../Users/UseCases/DeleteUser/DeleteUserHandler.cs:61-71). The reason is durability: a
best-effort call lost the instruction whenever storage was unreachable, while a queued row is
retried up to InternalCommands:MaxAttempts (default 5,
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Administration/InternalCommandsSettings.cs:39)
and then dead-lettered for operator action
(.../Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:461-467), so an outage longer
than the retry budget still needs a manual replay. Only the erasure path is fully transactional:
replace and remove schedule after SaveChangesAsync has committed
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:316-319),
so a host death between commit and scheduling, or a failed schedule, leaves an orphaned blob that
is only logged for manual removal (.../Users/UseCases/SetUserAvatar/SetUserAvatarHandler.cs:162-165,
.../Users/UseCases/RemoveUserAvatar/RemoveUserAvatarHandler.cs:78-81). Deletion is therefore eventual
and at-least-once once scheduled, not synchronous; the Decision and Consequences bullets above are corrected to say so. The public-read
container, the random suffix and the cache-TTL bound on staleness are unchanged.
Revision (2026-10-06)
No behavior changed; the dated sections above keep the anchors recorded on their dates, and the load-bearing ones have moved to:
- Decoded-size guard: the
Image.IdentifyAsyncheader read is now atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/ImageSharpImageProcessor.cs:48,MaxDecodedPixels(50,000,000) at:27,MaxDecodedDimension(20,000) at:34(the long-thin rationale is still at:30), and theTooLargeToDecodepredicate at:115; the post-decode re-check is at:70. - The abstract options-carrying
UploadAsyncis atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Storage/IFileStorageService.cs:37;AzureBlobFileStorageServiceimplements it atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Storage/AzureBlobFileStorageService.cs:28and writesContentDispositionandCacheControlat:42-43. - The
attempts_exhausteddead-letter is atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/InternalCommands/Processing/InternalCommandProcessor.cs:451-457. - The post-commit hook that replace and remove schedule from runs after
SaveChangesAsyncatMMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:322-325.
Anchors were re-verified against current source on this date.