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
@@ -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}",
@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
/// <summary>
@@ -20,8 +21,10 @@ public class AuthController : ControllerBase
/// </summary>
[HttpPost("login")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
[ProducesResponseType(typeof(ApiResponse<LoginResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> Login([FromBody] LoginRequest req)
{
var result = await _auth.LoginAsync(req);
@@ -33,8 +36,10 @@ public class AuthController : ControllerBase
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[EnableRateLimiting("auth")]
[ProducesResponseType(typeof(ApiResponse<TokenResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status422UnprocessableEntity)]
[ProducesResponseType(StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest req)
{
var result = await _auth.RefreshAsync(req);
@@ -1,6 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
/// <summary>
@@ -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<string> _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;
}
/// <summary>
@@ -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<BatchDetailResponse>.Ok(
BatchDetailResponse.FromEntity(batch, presignedUrl)));
}
@@ -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" }
});
}
@@ -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}'")
};
}
@@ -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.");
}
}
+98 -3
View File
@@ -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<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
var minioOptions = builder.Configuration.GetSection(MinioOptions.Section).Get<MinioOptions>()!;
@@ -43,6 +52,13 @@ try
// JWT Authentication
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)
.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<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
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<IBatchService, BatchService>();
@@ -79,6 +124,20 @@ try
builder.Services.AddHostedService<MetricsCollectorService>();
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.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"))
{
@@ -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();
@@ -9,6 +9,8 @@
</PropertyGroup>
<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="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.27" />
+3
View File
@@ -61,6 +61,9 @@
"MIXED": true
}
},
"Cors": {
"AllowedOrigins": [ "http://localhost:3028" ]
},
"PromotionRetry": {
"PollIntervalSeconds": 60,
"InitialDelaySeconds": 30,