Files
jobtrackingapp/JobTrackerApi/Services/PdfRenderAdmission.cs
T
cesnimda 0954876295 fix(security): enforce audited account, network and resource boundaries
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
2026-09-09 18:21:47 +02:00

77 lines
3.0 KiB
C#

using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
namespace JobTrackerApi.Services;
public sealed class PdfRenderOptions
{
// One browser at a time until the deployment's memory/CPU budget is measured (R04).
[Range(1, 8)] public int MaxConcurrentRenders { get; set; } = 1;
[Range(1, 60)] public int RenderTimeoutSeconds { get; set; } = 60;
}
public sealed class PdfCapacityException : Exception
{
public PdfCapacityException() : base("PDF generation is busy. Please try again shortly.") { }
}
public sealed class PdfRenderTimeoutException : TimeoutException
{
public PdfRenderTimeoutException() : base("PDF generation took too long. Please try again.") { }
}
// Registered once for the API process, shared even by separately constructed exporters.
// ponytail: zero waiting, one active render per document owner; measure before adding a scheduler.
public sealed class PdfRenderAdmission
{
private readonly HashSet<string> _owners = new(StringComparer.Ordinal);
private readonly int _limit;
private readonly ILogger<PdfRenderAdmission> _logger;
private bool _disabled;
public TimeSpan RenderTimeout { get; }
public PdfRenderAdmission(IOptions<PdfRenderOptions> options, ILogger<PdfRenderAdmission> logger)
{
var value = options.Value;
Validator.ValidateObject(value, new ValidationContext(value), validateAllProperties: true);
_limit = value.MaxConcurrentRenders;
RenderTimeout = TimeSpan.FromSeconds(value.RenderTimeoutSeconds);
_logger = logger;
}
public IDisposable Enter(string ownerUserId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
lock (_owners)
{
cancellationToken.ThrowIfCancellationRequested();
if (_disabled || _owners.Count >= _limit || _owners.Contains(ownerUserId))
{
_logger.LogDebug("PDF admission rejected; active={Active}, limit={Limit}, disabled={Disabled}", _owners.Count, _limit, _disabled);
throw new PdfCapacityException();
}
// Log before reserving: even a failing logging provider must not strand a slot.
_logger.LogDebug("PDF admission granted; active={Active}, limit={Limit}", _owners.Count + 1, _limit);
_owners.Add(ownerUserId);
return new Lease(this, ownerUserId);
}
}
// If the OS cannot confirm termination, never replace possibly still-running work.
// This is a visible fail-closed fault, requiring operator inspection/restart.
public void Disable()
{
lock (_owners) _disabled = true;
}
private sealed class Lease(PdfRenderAdmission admission, string owner) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
lock (admission._owners) admission._owners.Remove(owner);
}
}
}