Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AuditLogController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

231 lines
9.2 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only viewer for the application audit log, which records who changed
/// what entity and when. Audit log records are never soft-deleted or modified by
/// the application — they are append-only by design to maintain an unambiguous
/// trail of changes across all tenants.
/// </summary>
// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AuditLogController : Controller
{
private readonly ApplicationDbContext _db;
private readonly ILogger<AuditLogController> _logger;
private readonly IPlatformSettingsService _platformSettings;
public AuditLogController(ApplicationDbContext db, ILogger<AuditLogController> logger,
IPlatformSettingsService platformSettings)
{
_db = db;
_logger = logger;
_platformSettings = platformSettings;
}
/// <summary>
/// Renders a paginated, filterable audit log across all companies. Supports
/// free-text search on username, entity description, and entity Id; and
/// structured filters for entity type, action verb, company, and date range.
/// <para>
/// Date filters are converted to UTC before querying because audit log timestamps
/// are stored in UTC. The <c>to</c> date filter adds one day so that entries
/// timestamped at any point during the selected end-date are included.
/// Entity-type and action dropdowns are populated from distinct values in the
/// table (not from a static enum) so the UI always reflects exactly what types
/// and actions are present in the log.
/// </para>
/// </summary>
public async Task<IActionResult> Index(
string? search,
string? entityType,
[FromQuery] string? action,
int? companyId,
DateTime? from,
DateTime? to,
int page = 1,
int pageSize = 50)
{
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.AuditLogs.AsNoTracking();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(a =>
a.UserName.Contains(search) ||
(a.EntityDescription != null && a.EntityDescription.Contains(search)) ||
(a.EntityId != null && a.EntityId.Contains(search)));
if (!string.IsNullOrWhiteSpace(entityType))
query = query.Where(a => a.EntityType == entityType);
if (!string.IsNullOrWhiteSpace(action))
query = query.Where(a => a.Action == action);
if (companyId.HasValue)
query = query.Where(a => a.CompanyId == companyId);
if (from.HasValue)
query = query.Where(a => a.Timestamp >= from.Value.ToUniversalTime());
if (to.HasValue)
query = query.Where(a => a.Timestamp < to.Value.ToUniversalTime().AddDays(1));
var totalCount = await query.CountAsync();
var logs = await query
.OrderByDescending(a => a.Timestamp)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Populate filter dropdowns from distinct values
ViewBag.EntityTypes = await _db.AuditLogs.AsNoTracking()
.Select(a => a.EntityType).Distinct().OrderBy(x => x).ToListAsync();
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName }).ToListAsync();
ViewBag.Search = search;
ViewBag.EntityType = entityType;
ViewBag.Action = action;
ViewBag.CompanyId = companyId;
ViewBag.From = from?.ToString("yyyy-MM-dd");
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.Page = page;
ViewBag.PageSize = pageSize;
ViewBag.TotalCount = totalCount;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
return View(logs);
}
/// <summary>
/// Returns the full detail view for a single audit log entry, including the
/// before/after JSON snapshot stored in the record. Uses <c>long</c> for the
/// Id parameter because audit log primary keys are 64-bit integers to
/// accommodate high-volume writes without risk of overflow.
/// </summary>
public async Task<IActionResult> Details(long id)
{
var log = await _db.AuditLogs.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id);
if (log == null) return NotFound();
return View(log);
}
/// <summary>
/// Diagnostic action — tests a live INSERT into AuditLogs and reports table state.
/// Useful for diagnosing why prod shows 0 entries: isolates whether the table is
/// writable, what the retention setting is, and whether any rows exist at all.
/// Returns JSON so it can be called from the browser address bar without a view.
/// </summary>
[HttpGet]
public async Task<IActionResult> Diagnostics()
{
var results = new Dictionary<string, object?>();
// 1. Row count
try
{
results["rowCount"] = await _db.AuditLogs.AsNoTracking().CountAsync();
}
catch (Exception ex)
{
results["rowCountError"] = ex.Message;
}
// 2. Oldest and newest timestamps
try
{
var oldest = await _db.AuditLogs.AsNoTracking().MinAsync(a => (DateTime?)a.Timestamp);
var newest = await _db.AuditLogs.AsNoTracking().MaxAsync(a => (DateTime?)a.Timestamp);
results["oldestEntry"] = oldest;
results["newestEntry"] = newest;
}
catch (Exception ex)
{
results["timestampError"] = ex.Message;
}
// 3. Retention setting
try
{
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AuditLogRetentionDays);
results["retentionRaw"] = raw;
results["retentionDays"] = int.TryParse(raw, out var d) ? d : 365;
results["retentionCutoff"] = DateTime.UtcNow.AddDays(-(int.TryParse(raw, out var d2) ? d2 : 365));
}
catch (Exception ex)
{
results["retentionError"] = ex.Message;
}
// 4. Test direct INSERT via EF (same path used by WriteLoginAuditAsync)
string? insertError = null;
long? insertedId = null;
try
{
var testEntry = new AuditLog
{
UserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
UserName = User.Identity?.Name ?? "Diagnostics",
Action = "DiagnosticsTest",
EntityType = "AuditLog",
EntityId = "diag",
EntityDescription = "Diagnostic test write",
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
Timestamp = DateTime.UtcNow
};
_db.AuditLogs.Add(testEntry);
await _db.SaveChangesAsync();
insertedId = testEntry.Id;
results["testInsertId"] = insertedId;
results["testInsertSuccess"] = true;
// Clean it up immediately
_db.AuditLogs.Remove(testEntry);
await _db.SaveChangesAsync();
results["testInsertCleanedUp"] = true;
}
catch (Exception ex)
{
insertError = ex.Message;
results["testInsertSuccess"] = false;
results["testInsertError"] = ex.Message;
results["testInsertInner"] = ex.InnerException?.Message;
}
// 5. Connection string info (safe — shows server/db only, no credentials)
try
{
var conn = _db.Database.GetConnectionString() ?? "";
// Extract Server= and Database= segments only (no passwords)
var safeConn = string.Join("; ", conn.Split(';')
.Where(p => p.TrimStart().StartsWith("Server", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Database", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Data Source", StringComparison.OrdinalIgnoreCase)
|| p.TrimStart().StartsWith("Initial Catalog", StringComparison.OrdinalIgnoreCase)));
results["connectionInfo"] = safeConn;
}
catch (Exception ex)
{
results["connectionError"] = ex.Message;
}
results["serverUtcNow"] = DateTime.UtcNow;
results["environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "unknown";
_logger.LogInformation("Audit log diagnostics run by {User}: {@Results}", User.Identity?.Name, results);
return Json(results, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
}
}