fix: do up No audit of document access in vigilcare-records-gap-analysis.md
This commit is contained in:
@@ -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<string, string> TokenCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Logs in as the given user and returns an HttpClient with the JWT
|
||||
/// Authorization header pre-configured. Login also returns a refresh token;
|
||||
@@ -11,16 +14,22 @@ public static class AuthHelper
|
||||
public static async Task<HttpClient> 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<JsonDocument>();
|
||||
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<JsonDocument>();
|
||||
var token = body!.RootElement.GetProperty("data").GetProperty("token").GetString()!;
|
||||
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -99,11 +158,47 @@ try
|
||||
}
|
||||
|
||||
app.UseHttpMetrics();
|
||||
app.UseCors("VigilCare");
|
||||
if (!app.Environment.IsEnvironment("Testing"))
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
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"))
|
||||
{
|
||||
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
|
||||
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" />
|
||||
|
||||
@@ -61,6 +61,9 @@
|
||||
"MIXED": true
|
||||
}
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:3028" ]
|
||||
},
|
||||
"PromotionRetry": {
|
||||
"PollIntervalSeconds": 60,
|
||||
"InitialDelaySeconds": 30,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user