Skip to content

Attachment Service

The Attachment service handles all file uploads, downloads, and document management for Microtec ERP. It abstracts Azure Blob Storage behind a single API so that any ERP microservice can attach files to records without managing storage directly.


Overview

PropertyValue
ServiceAttachment.Apis
CAE placementPrivate CAE
Gateway route prefix/attachment-apis/
SourcePlatforms/Src/InfrastructureServices/Attachment/
Storage backendAzure Blob Storage
Tenant isolationSeparate blob container per tenant
Auth realmmicrotec (Keycloak)

Architecture

Storage Account Location

Attachment blobs are stored in the frontend MFE storage account (micerpfrdevsa / micerpfrstgsa / etc.), not a dedicated backend storage account. This allows the frontend to generate short-lived SAS URLs and serve files directly to the browser without proxying through the API.


Tenant Isolation

Each tenant gets its own blob container within the shared storage account:

micerpfrdevsa (Storage Account)
└── containers/
    ├── tenant-001/
    │   ├── invoices/
    │   ├── contracts/
    │   └── profiles/
    ├── tenant-002/
    │   └── ...
    └── tenant-003/
        └── ...

The container name is derived from the tenant code at upload time. Cross-tenant access is enforced in the Attachment service — a request with tenant A's JWT cannot download files from tenant B's container.


Domain Model

EntityType and EntityId allow the service to associate any file with any record in any service (e.g., EntityType = "Invoice", EntityId = 1234).


API Endpoints

MethodRouteOperationNotes
POST/Attachments/UploadUpload one or more filesMultipart form data
GET/Attachments/{Id}Get attachment metadataReturns metadata only
GET/Attachments/{Id}/DownloadDownload file bytesStreams blob content
GET/Attachments/{Id}/SasUrlGenerate short-lived SAS URLFrontend direct download
GET/Attachments/ByEntityList attachments for a recordFilter by EntityType + EntityId
DELETE/Attachments/{Id}Soft-delete attachmentSets IsDeleted = true

Upload Request

http
POST /attachment-apis/Attachments/Upload
Authorization: Bearer <token>
Content-Type: multipart/form-data

--boundary
Content-Disposition: form-data; name="EntityType"
Invoice
--boundary
Content-Disposition: form-data; name="EntityId"
1234
--boundary
Content-Disposition: form-data; name="files"; filename="invoice-001.pdf"
Content-Type: application/pdf

<binary>

Upload Response

json
{
  "success": true,
  "data": [
    {
      "id": 5001,
      "originalFileName": "invoice-001.pdf",
      "contentType": "application/pdf",
      "sizeBytes": 204800,
      "uploadedAt": "2026-01-15T09:30:00Z"
    }
  ]
}

SAS URL Response

json
{
  "success": true,
  "data": {
    "url": "https://micerpfrdevsa.blob.core.windows.net/tenant-001/invoices/invoice-001.pdf?sv=2023-01-03&se=2026-01-15T10%3A30%3A00Z&sr=b&sp=r&sig=...",
    "expiresAt": "2026-01-15T10:30:00Z"
  }
}

SAS URLs expire after 1 hour by default. The frontend requests a fresh SAS URL each time a user wants to view or download a file.


IAttachmentPublicApi

Other microservices call the Attachment service via the typed HTTP client:

csharp
// Defined in Microtec.PublicApi.AppsPortal (or service-specific PublicApi project)
public interface IAttachmentPublicApi
{
    /// <summary>Upload files and associate with an entity record.</summary>
    Task<List<AttachmentDto>> UploadAsync(
        string entityType,
        int entityId,
        IEnumerable<IFormFile> files,
        CancellationToken ct = default);

    /// <summary>Get all attachments for a given entity record.</summary>
    Task<List<AttachmentDto>> GetByEntityAsync(
        string entityType,
        int entityId,
        CancellationToken ct = default);

    /// <summary>Generate a time-limited SAS download URL.</summary>
    Task<string> GetSasUrlAsync(int attachmentId, CancellationToken ct = default);

    /// <summary>Soft-delete an attachment.</summary>
    Task DeleteAsync(int attachmentId, CancellationToken ct = default);
}

Usage in a Command Handler

csharp
// Inside AddContractCommandHandler
public class AddContractCommandHandler(
    IUnitOfWork<AppDbContext> unitOfWork,
    IAttachmentPublicApi attachmentApi) : IRequestHandler<AddContractCommand, ApiResponse<int>>
{
    public async Task<ApiResponse<int>> Handle(AddContractCommand request, CancellationToken ct)
    {
        var contract = Contract.Create(
            request.Dto.EmployeeId,
            request.Dto.StartDate,
            request.Dto.EndDate);

        await unitOfWork.Repository<Contract>().AddAsync(contract, ct);
        await unitOfWork.SaveChangesAsync(ct);

        // Upload contract document if provided
        if (request.Dto.Documents?.Count > 0)
        {
            await attachmentApi.UploadAsync(
                entityType: "Contract",
                entityId: contract.Id,
                files: request.Dto.Documents,
                ct: ct);
        }

        return ApiResponse.Success(contract.Id);
    }
}

Upload Implementation Detail

csharp
// Attachment service — AddAttachmentCommandHandler.cs
public class AddAttachmentCommandHandler(
    IBlobStorageService blobStorage,
    IUnitOfWork<AttachmentDbContext> unitOfWork,
    ITenantProvider tenant) : IRequestHandler<AddAttachmentCommand, ApiResponse<List<AttachmentDto>>>
{
    public async Task<ApiResponse<List<AttachmentDto>>> Handle(
        AddAttachmentCommand request, CancellationToken ct)
    {
        var results = new List<AttachmentDto>();
        var container = $"tenant-{tenant.TenantCode.ToLowerInvariant()}";

        foreach (var file in request.Files)
        {
            var blobName = $"{request.EntityType.ToLowerInvariant()}/{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";

            await blobStorage.UploadAsync(container, blobName, file.OpenReadStream(), file.ContentType, ct);

            var attachment = Attachment.Create(
                blobName, container, file.FileName, file.ContentType,
                file.Length, request.EntityType, request.EntityId, tenant.TenantId);

            await unitOfWork.Repository<Attachment>().AddAsync(attachment, ct);
            results.Add(attachment.ToDto());
        }

        await unitOfWork.SaveChangesAsync(ct);
        return ApiResponse.Success(results);
    }
}

Allowed File Types

The service enforces a whitelist of permitted MIME types:

CategoryAllowed Types
Documentsapplication/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document
Spreadsheetsapplication/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Imagesimage/jpeg, image/png, image/gif, image/webp
Archivesapplication/zip

Executable Files Blocked

Files with .exe, .dll, .sh, .ps1, .bat extensions are rejected regardless of MIME type. The validator checks both the content type header and the file signature (magic bytes).


File Size Limits

LimitValue
Single file max50 MB
Total request max200 MB
Simultaneous uploads10 files per request

Configuration

json
// appsettings.json (Attachment.Apis)
{
  "ConnectionStrings": {
    "AdminConnection": "Server=...;Database=MicrotecAdmin;..."
  },
  "AzureBlob": {
    "AccountName": "micerpfrdevsa",
    "AccountKey": "<keyvault-ref>",
    "SasTokenExpiryMinutes": 60
  },
  "Keycloak": {
    "Authority": "https://<keycloak-host>/realms/microtec",
    "Audience": "account"
  },
  "AllowedFileExtensions": [".pdf", ".doc", ".docx", ".xls", ".xlsx", ".jpg", ".jpeg", ".png", ".zip"]
}

Key Vault Reference

In production, AzureBlob:AccountKey is stored in Azure Key Vault and referenced via the keyvaultref: pattern. The Container App environment resolves it at startup.


Deployment

The Attachment service is listed in the pipeline service config:

Devops/azure/config/container-backend/services-config.json  →  "attachment"

The managed identity assigned to the Attachment Container App requires Storage Blob Data Contributor role on the frontend storage account to perform upload and SAS generation operations.

Internal Documentation — Microtec Platform Team