fix: do up No audit of document access in vigilcare-records-gap-analysis.md

This commit is contained in:
voltsrage
2026-06-27 15:25:18 +08:00
parent 46c3492bb9
commit 5a2e95c984
12 changed files with 220 additions and 24 deletions
@@ -1,8 +1,11 @@
using System.Collections.Concurrent;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
public static class AuthHelper public static class AuthHelper
{ {
private static readonly ConcurrentDictionary<string, string> TokenCache = new();
/// <summary> /// <summary>
/// Logs in as the given user and returns an HttpClient with the JWT /// Logs in as the given user and returns an HttpClient with the JWT
/// Authorization header pre-configured. Login also returns a refresh token; /// Authorization header pre-configured. Login also returns a refresh token;
@@ -11,16 +14,22 @@ public static class AuthHelper
public static async Task<HttpClient> LoginAsync( public static async Task<HttpClient> LoginAsync(
ApiFixture fixture, string username = "entry1", string password = "password") ApiFixture fixture, string username = "entry1", string password = "password")
{ {
var client = fixture.CreateClient(); var cacheKey = $"{username}:{password}";
var loginResp = await client.PostAsJsonAsync("/api/v1/auth/login", if (!TokenCache.TryGetValue(cacheKey, out var token))
{
var loginClient = fixture.CreateClient();
var loginResp = await loginClient.PostAsJsonAsync("/api/v1/auth/login",
new { username, password }); new { username, password });
loginResp.EnsureSuccessStatusCode(); loginResp.EnsureSuccessStatusCode();
var body = await loginResp.Content.ReadFromJsonAsync<JsonDocument>(); var body = await loginResp.Content.ReadFromJsonAsync<JsonDocument>();
var token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!; token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!;
TokenCache[cacheKey] = token;
}
var client = fixture.CreateClient();
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
@@ -136,6 +136,8 @@ public class PromotionRetryService : BackgroundService
await promotionService.PromoteAsync(batchId, approverUserId); await promotionService.PromoteAsync(batchId, approverUserId);
// --- Success path --- // --- Success path ---
DiagnosticsMetrics.PromotionRetryTotal.WithLabels("success").Inc();
var successAttempt = new PromotionAttempt var successAttempt = new PromotionAttempt
{ {
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
@@ -190,6 +192,7 @@ public class PromotionRetryService : BackgroundService
if (nextAttemptNumber < _options.MaxRetryAttempts) if (nextAttemptNumber < _options.MaxRetryAttempts)
{ {
nextRetryAt = DateTimeOffset.UtcNow.Add(delay); nextRetryAt = DateTimeOffset.UtcNow.Add(delay);
DiagnosticsMetrics.PromotionRetryTotal.WithLabels("failure").Inc();
_logger.LogInformation( _logger.LogInformation(
"Scheduling retry for batch {BatchId} at {NextRetryAt} " + "Scheduling retry for batch {BatchId} at {NextRetryAt} " +
@@ -199,7 +202,8 @@ public class PromotionRetryService : BackgroundService
} }
else else
{ {
// Exhausted all retries — manual intervention required DiagnosticsMetrics.PromotionRetryTotal.WithLabels("exhausted").Inc();
_logger.LogCritical( _logger.LogCritical(
"Batch {BatchId} has exhausted all {Max} retry attempts. " + "Batch {BatchId} has exhausted all {Max} retry attempts. " +
"Manual intervention required. Last error: {Error}", "Manual intervention required. Last error: {Error}",
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
/// <summary> /// <summary>
@@ -20,8 +21,10 @@ public class AuthController : ControllerBase
/// </summary> /// </summary>
[HttpPost("login")] [HttpPost("login")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth")]
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> Login([FromBody] LoginRequest req) public async Task<IActionResult> Login([FromBody] LoginRequest req)
{ {
var result = await _auth.LoginAsync(req); var result = await _auth.LoginAsync(req);
@@ -33,8 +36,10 @@ public class AuthController : ControllerBase
/// </summary> /// </summary>
[HttpPost("refresh")] [HttpPost("refresh")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting("auth")]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)] [ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req) public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
{ {
var result = await _auth.RefreshAsync(req); var result = await _auth.RefreshAsync(req);
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary> /// <summary>
@@ -16,6 +17,7 @@ public class DigitizationBatchesController : ControllerBase
private readonly IDocumentStorageService _storage; private readonly IDocumentStorageService _storage;
private readonly IPromotionService _promotion; private readonly IPromotionService _promotion;
private readonly IBatchEventService _batchEventService; private readonly IBatchEventService _batchEventService;
private readonly AppDbContext _db;
private static readonly HashSet<string> _allowedMimeTypes = new() private static readonly HashSet<string> _allowedMimeTypes = new()
{ {
@@ -26,12 +28,14 @@ public class DigitizationBatchesController : ControllerBase
IBatchService batches, IBatchService batches,
IDocumentStorageService storage, IDocumentStorageService storage,
IPromotionService promotion, IPromotionService promotion,
IBatchEventService batchEventService) IBatchEventService batchEventService,
AppDbContext db)
{ {
_batches = batches; _batches = batches;
_storage = storage; _storage = storage;
_promotion = promotion; _promotion = promotion;
_batchEventService = batchEventService; _batchEventService = batchEventService;
_db = db;
} }
/// <summary> /// <summary>
@@ -83,6 +87,27 @@ public class DigitizationBatchesController : ControllerBase
var batch = await _batches.GetByIdAsync(id); var batch = await _batches.GetByIdAsync(id);
var presignedUrl = await _storage.GetPresignedUrlAsync(batch.DocumentRef); 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<BatchDetailResponse>.Ok( return Ok(ApiResponse<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl))); BatchDetailResponse.FromEntity(batch, presignedUrl)));
} }
@@ -86,4 +86,12 @@ public static class DiagnosticsMetrics
public static readonly Gauge QueueAgeSeconds = Metrics.CreateGauge( public static readonly Gauge QueueAgeSeconds = Metrics.CreateGauge(
"digitization_queue_age_seconds", "digitization_queue_age_seconds",
"Age in seconds of the oldest batch in pending_verification status."); "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" }
});
} }
@@ -18,7 +18,8 @@ public enum DigitizationEventType
PromotionRetrySucceeded, PromotionRetrySucceeded,
PromotionRetryFailed, PromotionRetryFailed,
PromotionRetryExhausted, PromotionRetryExhausted,
PromotionFailed PromotionFailed,
DocumentAccessed
} }
public static class DigitizationEventTypeExtensions public static class DigitizationEventTypeExtensions
@@ -44,6 +45,7 @@ public static class DigitizationEventTypeExtensions
DigitizationEventType.PromotionRetryFailed => "promotion_retry_failed", DigitizationEventType.PromotionRetryFailed => "promotion_retry_failed",
DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted", DigitizationEventType.PromotionRetryExhausted => "promotion_retry_exhausted",
DigitizationEventType.PromotionFailed => "promotion_failed", DigitizationEventType.PromotionFailed => "promotion_failed",
DigitizationEventType.DocumentAccessed => "document_accessed",
_ => throw new ArgumentOutOfRangeException(nameof(t)) _ => throw new ArgumentOutOfRangeException(nameof(t))
}; };
@@ -68,6 +70,7 @@ public static class DigitizationEventTypeExtensions
"promotion_retry_failed" => DigitizationEventType.PromotionRetryFailed, "promotion_retry_failed" => DigitizationEventType.PromotionRetryFailed,
"promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted, "promotion_retry_exhausted" => DigitizationEventType.PromotionRetryExhausted,
"promotion_failed" => DigitizationEventType.PromotionFailed, "promotion_failed" => DigitizationEventType.PromotionFailed,
"document_accessed" => DigitizationEventType.DocumentAccessed,
_ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'") _ => throw new ArgumentOutOfRangeException(nameof(v), $"Unknown digitization event type: '{v}'")
}; };
} }
@@ -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<MinioOptions> options)
{
_minio = minio;
_bucketName = options.Value.BucketName;
}
public async Task<HealthCheckResult> 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.");
}
}
+96 -1
View File
@@ -1,6 +1,10 @@
using System.Text; using System.Text;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using Minio; using Minio;
using Prometheus; using Prometheus;
@@ -24,7 +28,12 @@ try
// Redis // Redis
builder.Services.AddSingleton<IConnectionMultiplexer>(sp => builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
ConnectionMultiplexer.Connect(sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!)); {
var config = ConfigurationOptions.Parse(
sp.GetRequiredService<IConfiguration>()["Redis:ConnectionString"]!);
config.AbortOnConnectFail = false;
return ConnectionMultiplexer.Connect(config);
});
// MinIO // MinIO
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!; var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
@@ -43,6 +52,13 @@ try
// JWT Authentication // JWT Authentication
var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!; var jwtOptions = builder.Configuration.GetSection(JwtOptions.Section).Get<JwtOptions>()!;
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) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => .AddJwtBearer(options =>
{ {
@@ -59,6 +75,35 @@ try
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization();
// CORS
builder.Services.AddCors(options =>
{
options.AddPolicy("VigilCare", policy =>
{
policy.WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()!)
.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 // Services
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IBatchService, BatchService>(); builder.Services.AddScoped<IBatchService, BatchService>();
@@ -79,6 +124,20 @@ try
builder.Services.AddHostedService<MetricsCollectorService>(); builder.Services.AddHostedService<MetricsCollectorService>();
builder.Services.AddHostedService<PromotionRetryService>(); builder.Services.AddHostedService<PromotionRetryService>();
// 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<MinioHealthCheck>(
"minio",
tags: new[] { "ready" });
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddVigilCareRecordsSwagger(); builder.Services.AddVigilCareRecordsSwagger();
@@ -99,11 +158,47 @@ try
} }
app.UseHttpMetrics(); app.UseHttpMetrics();
app.UseCors("VigilCare");
if (!app.Environment.IsEnvironment("Testing"))
app.UseRateLimiter();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.MapControllers(); 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")) if (!app.Environment.IsEnvironment("Testing"))
{ {
using var scope = app.Services.CreateScope(); using var scope = app.Services.CreateScope();
@@ -73,6 +73,18 @@ public class BatchService : IBatchService
// Duplicate detection: same SHA-256 for same patient within 24 hours // Duplicate detection: same SHA-256 for same patient within 24 hours
if (patientId.HasValue) 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 cutoff = DateTimeOffset.UtcNow.AddHours(-24);
var duplicate = await _db.DigitizationBatches.AnyAsync(b => var duplicate = await _db.DigitizationBatches.AnyAsync(b =>
b.DocumentSha256 == sha256 && b.DocumentSha256 == sha256 &&
@@ -80,10 +92,13 @@ public class BatchService : IBatchService
b.CreatedAt >= cutoff); b.CreatedAt >= cutoff);
if (duplicate) if (duplicate)
{
await cache.KeyDeleteAsync(dedupKey);
throw new ConflictException( throw new ConflictException(
"A document with the same content was uploaded for this patient within the last 24 hours.", "A document with the same content was uploaded for this patient within the last 24 hours.",
"DUPLICATE_DOCUMENT"); "DUPLICATE_DOCUMENT");
} }
}
var batchId = Guid.NewGuid(); var batchId = Guid.NewGuid();
var batch = new DigitizationBatch var batch = new DigitizationBatch
@@ -9,6 +9,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
+3
View File
@@ -61,6 +61,9 @@
"MIXED": true "MIXED": true
} }
}, },
"Cors": {
"AllowedOrigins": [ "http://localhost:3028" ]
},
"PromotionRetry": { "PromotionRetry": {
"PollIntervalSeconds": 60, "PollIntervalSeconds": 60,
"InitialDelaySeconds": 30, "InitialDelaySeconds": 30,
+8 -8
View File
@@ -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 | | 1 | PromoteAsync missing clinical entities | P0 | A | Done |
| 2 | Patient dedup by exact name+DOB | P0 | A | Done | | 2 | Patient dedup by exact name+DOB | P0 | A | Done |
| 3 | Batch assignment inconsistent state | P1 | A | Done | | 3 | Batch assignment inconsistent state | P1 | A | Done |
| 4 | Concurrent batch creation race | P1 | A | Open | | 4 | Concurrent batch creation race | P1 | A | Done |
| 5 | No health check endpoints | P2 | B | Open | | 5 | No health check endpoints | P2 | B | Done |
| 6 | No CORS configuration | P2 | B | Open | | 6 | No CORS configuration | P2 | B | Done |
| 7 | Redis failure crashes startup | P2 | B | Open | | 7 | Redis failure crashes startup | P2 | B | Done |
| 8 | Promotion retry metrics missing | P2 | B | Open | | 8 | Promotion retry metrics missing | P2 | B | Done |
| 9 | JWT key not validated on startup | P3 | C | Open | | 9 | JWT key not validated on startup | P3 | C | Done |
| 10 | No rate limiting on auth | P3 | C | Open | | 10 | No rate limiting on auth | P3 | C | Done |
| 11 | Credentials in plaintext config | P3 | C | Open | | 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 | | 13 | No FluentValidation | P2 | D | Open |
| 14 | No user management endpoints | P4 | D | Open | | 14 | No user management endpoints | P4 | D | Open |
| 15 | No batch cancel/void | P4 | D | Open | | 15 | No batch cancel/void | P4 | D | Open |