Appearance
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
| Property | Value |
|---|---|
| Service | Attachment.Apis |
| CAE placement | Private CAE |
| Gateway route prefix | /attachment-apis/ |
| Source | Platforms/Src/InfrastructureServices/Attachment/ |
| Storage backend | Azure Blob Storage |
| Tenant isolation | Separate blob container per tenant |
| Auth realm | microtec (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
| Method | Route | Operation | Notes |
|---|---|---|---|
POST | /Attachments/Upload | Upload one or more files | Multipart form data |
GET | /Attachments/{Id} | Get attachment metadata | Returns metadata only |
GET | /Attachments/{Id}/Download | Download file bytes | Streams blob content |
GET | /Attachments/{Id}/SasUrl | Generate short-lived SAS URL | Frontend direct download |
GET | /Attachments/ByEntity | List attachments for a record | Filter by EntityType + EntityId |
DELETE | /Attachments/{Id} | Soft-delete attachment | Sets 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:
| Category | Allowed Types |
|---|---|
| Documents | application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| Spreadsheets | application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
| Images | image/jpeg, image/png, image/gif, image/webp |
| Archives | application/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
| Limit | Value |
|---|---|
| Single file max | 50 MB |
| Total request max | 200 MB |
| Simultaneous uploads | 10 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.