1cb7a8ca4a
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>
231 lines
9.2 KiB
C#
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 });
|
|
}
|
|
}
|