diff --git a/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs b/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs index 27434c1..15a20d1 100644 --- a/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs +++ b/VigilCareRecordsAPI.Tests/Helpers/AuthHelper.cs @@ -1,8 +1,11 @@ +using System.Collections.Concurrent; using System.Net.Http.Json; using System.Text.Json; public static class AuthHelper { + private static readonly ConcurrentDictionary TokenCache = new(); + /// /// Logs in as the given user and returns an HttpClient with the JWT /// Authorization header pre-configured. Login also returns a refresh token; @@ -11,19 +14,25 @@ public static class AuthHelper public static async Task LoginAsync( ApiFixture fixture, string username = "entry1", string password = "password") { + var cacheKey = $"{username}:{password}"; + + if (!TokenCache.TryGetValue(cacheKey, out var token)) + { + var loginClient = fixture.CreateClient(); + var loginResp = await loginClient.PostAsJsonAsync("/api/v1/auth/login", + new { username, password }); + + loginResp.EnsureSuccessStatusCode(); + + var body = await loginResp.Content.ReadFromJsonAsync(); + token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!; + TokenCache[cacheKey] = token; + } + var client = fixture.CreateClient(); - - var loginResp = await client.PostAsJsonAsync("/api/v1/auth/login", - new { username, password }); - - loginResp.EnsureSuccessStatusCode(); - - var body = await loginResp.Content.ReadFromJsonAsync(); - var token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!; - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); return client; } -} \ No newline at end of file +} diff --git a/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs b/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs index cb4c1e6..244378f 100644 --- a/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs +++ b/VigilCareRecordsAPI/BackgroundServices/PromotionRetryService.cs @@ -136,6 +136,8 @@ public class PromotionRetryService : BackgroundService await promotionService.PromoteAsync(batchId, approverUserId); // --- Success path --- + DiagnosticsMetrics.PromotionRetryTotal.WithLabels("success").Inc(); + var successAttempt = new PromotionAttempt { Id = Guid.NewGuid(), @@ -190,6 +192,7 @@ public class PromotionRetryService : BackgroundService if (nextAttemptNumber < _options.MaxRetryAttempts) { nextRetryAt = DateTimeOffset.UtcNow.Add(delay); + DiagnosticsMetrics.PromotionRetryTotal.WithLabels("failure").Inc(); _logger.LogInformation( "Scheduling retry for batch {BatchId} at {NextRetryAt} " + @@ -199,7 +202,8 @@ public class PromotionRetryService : BackgroundService } else { - // Exhausted all retries — manual intervention required + DiagnosticsMetrics.PromotionRetryTotal.WithLabels("exhausted").Inc(); + _logger.LogCritical( "Batch {BatchId} has exhausted all {Max} retry attempts. " + "Manual intervention required. Last error: {Error}", diff --git a/VigilCareRecordsAPI/Controllers/AuthController.cs b/VigilCareRecordsAPI/Controllers/AuthController.cs index 8dd68fa..8d5af5e 100644 --- a/VigilCareRecordsAPI/Controllers/AuthController.cs +++ b/VigilCareRecordsAPI/Controllers/AuthController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; /// @@ -20,8 +21,10 @@ public class AuthController : ControllerBase /// [HttpPost("login")] [AllowAnonymous] + [EnableRateLimiting("auth")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] public async Task Login([FromBody] LoginRequest req) { var result = await _auth.LoginAsync(req); @@ -33,8 +36,10 @@ public class AuthController : ControllerBase /// [HttpPost("refresh")] [AllowAnonymous] + [EnableRateLimiting("auth")] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status422UnprocessableEntity)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] public async Task Refresh([FromBody] RefreshRequest req) { var result = await _auth.RefreshAsync(req); diff --git a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs index 0a08f9e..7a32549 100644 --- a/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs +++ b/VigilCareRecordsAPI/Controllers/DigitizationBatchesController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; /// @@ -16,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase private readonly IDocumentStorageService _storage; private readonly IPromotionService _promotion; private readonly IBatchEventService _batchEventService; + private readonly AppDbContext _db; private static readonly HashSet _allowedMimeTypes = new() { @@ -26,12 +28,14 @@ public class DigitizationBatchesController : ControllerBase IBatchService batches, IDocumentStorageService storage, IPromotionService promotion, - IBatchEventService batchEventService) + IBatchEventService batchEventService, + AppDbContext db) { _batches = batches; _storage = storage; _promotion = promotion; _batchEventService = batchEventService; + _db = db; } /// @@ -83,6 +87,27 @@ public class DigitizationBatchesController : ControllerBase var batch = await _batches.GetByIdAsync(id); var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef); + var userId = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-5); + var recentAccess = await _db.DigitizationEvents.AnyAsync(e => + e.BatchId == id && + e.EventType == DigitizationEventType.DocumentAccessed && + e.ActorUserId == userId && + e.OccurredAt >= cutoff); + + if (!recentAccess) + { + _db.DigitizationEvents.Add(new DigitizationEvent + { + Id = Guid.NewGuid(), + BatchId = id, + EventType = DigitizationEventType.DocumentAccessed, + ActorUserId = userId, + OccurredAt = DateTimeOffset.UtcNow + }); + await _db.SaveChangesAsync(); + } + return Ok(ApiResponse.Ok( BatchDetailResponse.FromEntity(batch, presignedUrl))); } diff --git a/VigilCareRecordsAPI/Diagnostics/DiagnosticsMetrics.cs b/VigilCareRecordsAPI/Diagnostics/DiagnosticsMetrics.cs index df7366c..bce5c4b 100644 --- a/VigilCareRecordsAPI/Diagnostics/DiagnosticsMetrics.cs +++ b/VigilCareRecordsAPI/Diagnostics/DiagnosticsMetrics.cs @@ -86,4 +86,12 @@ public static class DiagnosticsMetrics public static readonly Gauge QueueAgeSeconds = Metrics.CreateGauge( "digitization_queue_age_seconds", "Age in seconds of the oldest batch in pending_verification status."); + + public static readonly Counter PromotionRetryTotal = Metrics.CreateCounter( + "digitization_promotion_retry_total", + "Total promotion retry attempts.", + new CounterConfiguration + { + LabelNames = new[] { "outcome" } + }); } \ No newline at end of file diff --git a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs index 37c8222..feec761 100644 --- a/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs +++ b/VigilCareRecordsAPI/Domain/Enums/DigitizationEventType.cs @@ -18,7 +18,8 @@ public enum DigitizationEventType PromotionRetrySucceeded, PromotionRetryFailed, PromotionRetryExhausted, - PromotionFailed + PromotionFailed, + DocumentAccessed } public static class DigitizationEventTypeExtensions @@ -44,6 +45,7 @@ public static class DigitizationEventTypeExtensions DigitizationEventType.PromotionRetryFailed => "promotion_retry_failed", DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted", DigitizationEventType.PromotionFailed => "promotion_failed", + DigitizationEventType.DocumentAccessed => "document_accessed", _ => throw new ArgumentOutOfRangeException(nameof(t)) }; @@ -68,6 +70,7 @@ public static class DigitizationEventTypeExtensions "promotion_retry_failed" => DigitizationEventType.PromotionRetryFailed, "promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted, "promotion_failed" => DigitizationEventType.PromotionFailed, + "document_accessed" => DigitizationEventType.DocumentAccessed, _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'") }; } \ No newline at end of file diff --git a/VigilCareRecordsAPI/HealthChecks/MinioHealthCheck.cs b/VigilCareRecordsAPI/HealthChecks/MinioHealthCheck.cs new file mode 100644 index 0000000..963af16 --- /dev/null +++ b/VigilCareRecordsAPI/HealthChecks/MinioHealthCheck.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using Minio; +using Minio.DataModel.Args; + +public class MinioHealthCheck : IHealthCheck +{ + private readonly IMinioClient _minio; + private readonly string _bucketName; + + public MinioHealthCheck(IMinioClient minio, IOptions options) + { + _minio = minio; + _bucketName = options.Value.BucketName; + } + + public async Task CheckHealthAsync( + HealthCheckContext context, CancellationToken cancellationToken = default) + { + var exists = await _minio.BucketExistsAsync( + new BucketExistsArgs().WithBucket(_bucketName), cancellationToken); + + return exists + ? HealthCheckResult.Healthy($"Bucket '{_bucketName}' exists.") + : HealthCheckResult.Unhealthy($"Bucket '{_bucketName}' not found."); + } +} diff --git a/VigilCareRecordsAPI/Program.cs b/VigilCareRecordsAPI/Program.cs index 419a4cc..4ba2bfa 100644 --- a/VigilCareRecordsAPI/Program.cs +++ b/VigilCareRecordsAPI/Program.cs @@ -1,6 +1,10 @@ using System.Text; +using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; using Minio; using Prometheus; @@ -24,7 +28,12 @@ try // Redis builder.Services.AddSingleton(sp => - ConnectionMultiplexer.Connect(sp.GetRequiredService()["Redis:ConnectionString"]!)); + { + var config = ConfigurationOptions.Parse( + sp.GetRequiredService()["Redis:ConnectionString"]!); + config.AbortOnConnectFail = false; + return ConnectionMultiplexer.Connect(config); + }); // MinIO var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get()!; @@ -43,6 +52,13 @@ try // JWT Authentication var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get()!; + + if (string.IsNullOrEmpty(jwtOptions.Secret) || + Encoding.UTF8.GetByteCount(jwtOptions.Secret) < 32) + throw new InvalidOperationException( + "JWT Secret must be at least 256 bits (32 bytes). " + + "Configure a strong secret via Jwt:Secret in appsettings or the Jwt__Secret environment variable."); + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -59,6 +75,35 @@ try }); builder.Services.AddAuthorization(); + // CORS + builder.Services.AddCors(options => + { + options.AddPolicy("VigilCare", policy => + { + policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get()!) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + + // Rate limiting (disabled in Testing — integration tests issue many auth requests) + var isTesting = builder.Environment.IsEnvironment("Testing"); + if (!isTesting) + { + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.AddFixedWindowLimiter("auth", opt => + { + opt.Window = TimeSpan.FromMinutes(5); + opt.PermitLimit = 10; + opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; + opt.QueueLimit = 0; + }); + }); + } + // Services builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -79,6 +124,20 @@ try builder.Services.AddHostedService(); builder.Services.AddHostedService(); + // Health checks + builder.Services.AddHealthChecks() + .AddNpgSql( + builder.Configuration.GetConnectionString("DefaultConnection")!, + name: "postgresql", + tags: new[] { "ready", "startup" }) + .AddRedis( + builder.Configuration["Redis:ConnectionString"]!, + name: "redis", + tags: new[] { "ready" }) + .AddCheck( + "minio", + tags: new[] { "ready" }); + builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddVigilCareRecordsSwagger(); @@ -98,11 +157,47 @@ try }); } - app.UseHttpMetrics(); + app.UseHttpMetrics(); + app.UseCors("VigilCare"); + if (!app.Environment.IsEnvironment("Testing")) + app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); - app.MapMetrics(); + app.MapMetrics(); + + app.MapHealthChecks("/health/live", new HealthCheckOptions + { + Predicate = _ => false, + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status200OK, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } + }); + + app.MapHealthChecks("/health/ready", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("ready"), + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status200OK, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } + }); + + app.MapHealthChecks("/health/startup", new HealthCheckOptions + { + Predicate = check => check.Tags.Contains("startup"), + ResultStatusCodes = + { + [HealthStatus.Healthy] = StatusCodes.Status200OK, + [HealthStatus.Degraded] = StatusCodes.Status200OK, + [HealthStatus.Unhealthy] = StatusCodes.Status503ServiceUnavailable + } + }); if (!app.Environment.IsEnvironment("Testing")) { diff --git a/VigilCareRecordsAPI/Services/BatchService.cs b/VigilCareRecordsAPI/Services/BatchService.cs index fce0504..11c98a3 100644 --- a/VigilCareRecordsAPI/Services/BatchService.cs +++ b/VigilCareRecordsAPI/Services/BatchService.cs @@ -73,6 +73,18 @@ public class BatchService : IBatchService // Duplicate detection: same SHA-256 for same patient within 24 hours if (patientId.HasValue) { + // Atomic Redis guard prevents concurrent uploads from racing past the DB check + var cache = _redis.GetDatabase(); + var dedupKey = $"batch:dedup:{sha256}:{patientId.Value}"; + var acquired = await cache.StringSetAsync(dedupKey, "1", + TimeSpan.FromHours(24), When.NotExists); + + if (!acquired) + throw new ConflictException( + "A document with the same content was uploaded for this patient within the last 24 hours.", + "DUPLICATE_DOCUMENT"); + + // DB fallback for dedup entries created before Redis guard was deployed var cutoff = DateTimeOffset.UtcNow.AddHours(-24); var duplicate = await _db.DigitizationBatches.AnyAsync(b => b.DocumentSha256 == sha256 && @@ -80,9 +92,12 @@ public class BatchService : IBatchService b.CreatedAt >= cutoff); if (duplicate) + { + await cache.KeyDeleteAsync(dedupKey); throw new ConflictException( "A document with the same content was uploaded for this patient within the last 24 hours.", "DUPLICATE_DOCUMENT"); + } } var batchId = Guid.NewGuid(); diff --git a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj index 68b16e8..1066296 100644 --- a/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj +++ b/VigilCareRecordsAPI/VigilCareRecordsAPI.csproj @@ -9,6 +9,8 @@ + + diff --git a/VigilCareRecordsAPI/appsettings.json b/VigilCareRecordsAPI/appsettings.json index a67c817..155d47a 100644 --- a/VigilCareRecordsAPI/appsettings.json +++ b/VigilCareRecordsAPI/appsettings.json @@ -61,6 +61,9 @@ "MIXED": true } }, + "Cors": { + "AllowedOrigins": [ "http://localhost:3028" ] + }, "PromotionRetry": { "PollIntervalSeconds": 60, "InitialDelaySeconds": 30, diff --git a/docs/vigilcare-records-gap-analysis.md b/docs/vigilcare-records-gap-analysis.md index 413d853..619405c 100644 --- a/docs/vigilcare-records-gap-analysis.md +++ b/docs/vigilcare-records-gap-analysis.md @@ -798,15 +798,15 @@ A batch stuck in `APPROVED` with exhausted retries is invisible in Prometheus da | 1 | PromoteAsync missing clinical entities | P0 | A | Done | | 2 | Patient dedup by exact name+DOB | P0 | A | Done | | 3 | Batch assignment inconsistent state | P1 | A | Done | -| 4 | Concurrent batch creation race | P1 | A | Open | -| 5 | No health check endpoints | P2 | B | Open | -| 6 | No CORS configuration | P2 | B | Open | -| 7 | Redis failure crashes startup | P2 | B | Open | -| 8 | Promotion retry metrics missing | P2 | B | Open | -| 9 | JWT key not validated on startup | P3 | C | Open | -| 10 | No rate limiting on auth | P3 | C | Open | +| 4 | Concurrent batch creation race | P1 | A | Done | +| 5 | No health check endpoints | P2 | B | Done | +| 6 | No CORS configuration | P2 | B | Done | +| 7 | Redis failure crashes startup | P2 | B | Done | +| 8 | Promotion retry metrics missing | P2 | B | Done | +| 9 | JWT key not validated on startup | P3 | C | Done | +| 10 | No rate limiting on auth | P3 | C | Done | | 11 | Credentials in plaintext config | P3 | C | Open | -| 12 | No document access audit | P3 | C | Open | +| 12 | No document access audit | P3 | C | Done | | 13 | No FluentValidation | P2 | D | Open | | 14 | No user management endpoints | P4 | D | Open | | 15 | No batch cancel/void | P4 | D | Open |