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.
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); upload deletes the previous blob; the URL lives on the user entity as[Pii], nulled on anonymize with the blob deleted; 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 deletes its blob, but 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.