Toate articolele anterioare din această serie s-au concentrat pe prevenție: autentificare solidă, autorizare corectă, protecție împotriva atacurilor cunoscute. Dar prevenția nu e niciodată 100%. Întrebarea nu e dacă vei avea un incident de securitate, ci când — și cât de repede îl vei detecta și înțelege.
Audit logging înregistrează ce s-a întâmplat în sistem: cine a făcut ce, când, de unde, și cu ce rezultat. Security events sunt un subset special — evenimentele cu implicații de securitate: autentificări eșuate, acces la resurse sensibile, modificări de permisiuni, operații administrative. Împreună formează baza pentru detecție, investigație și conformitate.
1. Ce trebuie logat — și ce nu
Primul instinct e să loghezi totul. Greșeală — un volum uriaș de log-uri fără structură e la fel de inutil ca niciun log. Trebuie să loghezi evenimentele cu valoare de investigație.
Trebuie logat obligatoriu
- Autentificări reușite și eșuate (cu IP, user agent, timestamp)
- Logout explicit și expirare sesiune
- Modificări de parolă și email
- Activare / dezactivare MFA
- Creare, modificare și ștergere de conturi
- Modificări de roluri și permisiuni
- Acces la date sensibile (date financiare, date personale, documente confidențiale)
- Operații administrative (configurare sistem, export date, ștergeri în masă)
- Erori de autorizare (403 Forbidden) — indică tentative de acces neautorizat
- Rate limit depășit (429) — poate indica atac automatizat
- Modificări la entități critice din baza de date
Nu loga niciodată
- Parole sau hash-uri de parole
- Token-uri de autentificare (JWT, refresh tokens, API keys)
- Date de card bancar sau CVV
- Conținut complet al request-urilor cu date sensibile (body-uri cu parole, PII)
- Cookie-uri de sesiune
Regula de aur: un log de audit trebuie să poată răspunde la întrebarea „cine a făcut X la momentul T?” — fără să expună date care pot fi exploatate dacă log-urile sunt compromise.
2. Structura unui audit log
Un audit log util are o structură consistentă care permite filtrare, corelare și alertare automată. Câmpurile esențiale:
public record AuditEvent
{
// Identitate eveniment
public Guid EventId { get; init; } = Guid.NewGuid();
public string EventType { get; init; } = default!; // "auth.login.success"
public string Category { get; init; } = default!; // "Authentication"
public string Severity { get; init; } = default!; // "Info", "Warning", "Critical"
// Cine
public string? UserId { get; init; }
public string? UserName { get; init; }
public string? UserEmail { get; init; }
public string? IpAddress { get; init; }
public string? UserAgent { get; init; }
// Ce
public string Action { get; init; } = default!; // "Login"
public string? ResourceType { get; init; } // "Invoice"
public string? ResourceId { get; init; } // "INV-2025-001"
public bool Succeeded { get; init; }
public string? FailureReason { get; init; }
// Context
public string? RequestPath { get; init; }
public string? HttpMethod { get; init; }
public string? CorrelationId { get; init; } // leagă mai multe event-uri dintr-un request
public string? TenantId { get; init; } // pentru arhitecturi multi-tenant
// Când
public DateTime Timestamp { get; init; } = DateTime.UtcNow;
// Date adiționale structurate
public Dictionary<string, object>? AdditionalData { get; init; }
}
Convenție pentru EventType
Folosește un naming convention ierarhic, consistent — facilitează filtrarea și alertarea:
// Format: <domeniu>.<actiune>.<rezultat>
"auth.login.success"
"auth.login.failed"
"auth.logout"
"auth.password.changed"
"auth.mfa.enabled"
"auth.mfa.disabled"
"account.created"
"account.deleted"
"account.role.assigned"
"account.role.revoked"
"data.invoice.viewed"
"data.invoice.exported"
"data.user.personal.accessed"
"admin.config.changed"
"admin.bulk.delete"
"admin.impersonation.started"
3. Serviciu de audit logging
3.1 Interfață și implementare
public interface IAuditLogger
{
Task LogAsync(AuditEvent auditEvent);
Task LogAsync(string eventType, string action, bool succeeded,
string? resourceType = null, string? resourceId = null,
string? failureReason = null,
Dictionary<string, object>? additionalData = null);
}
public class AuditLogger(IHttpContextAccessor httpContextAccessor,
ILogger<AuditLogger> logger) : IAuditLogger
{
public Task LogAsync(AuditEvent auditEvent)
{
// Logăm ca structured log — Application Insights îl indexează automat
logger.LogInformation(
"[AUDIT] {EventType} | User: {UserId} | IP: {IpAddress} | " +
"Action: {Action} | Resource: {ResourceType}/{ResourceId} | " +
"Succeeded: {Succeeded} | Reason: {FailureReason}",
auditEvent.EventType,
auditEvent.UserId ?? "anonymous",
auditEvent.IpAddress,
auditEvent.Action,
auditEvent.ResourceType,
auditEvent.ResourceId,
auditEvent.Succeeded,
auditEvent.FailureReason);
return Task.CompletedTask;
}
public Task LogAsync(string eventType, string action, bool succeeded,
string? resourceType = null, string? resourceId = null,
string? failureReason = null,
Dictionary<string, object>? additionalData = null)
{
var context = httpContextAccessor.HttpContext;
var user = context?.User;
var auditEvent = new AuditEvent
{
EventType = eventType,
Action = action,
Category = eventType.Split('.')[0],
Severity = succeeded ? "Info" : "Warning",
UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier),
UserName = user?.FindFirstValue(ClaimTypes.Name),
UserEmail = user?.FindFirstValue(ClaimTypes.Email),
IpAddress = GetClientIp(context),
UserAgent = context?.Request.Headers.UserAgent.ToString(),
ResourceType = resourceType,
ResourceId = resourceId,
Succeeded = succeeded,
FailureReason = failureReason,
RequestPath = context?.Request.Path.ToString(),
HttpMethod = context?.Request.Method,
CorrelationId = context?.TraceIdentifier,
AdditionalData = additionalData
};
return LogAsync(auditEvent);
}
private static string? GetClientIp(HttpContext? context)
{
if (context is null) return null;
// Citim IP-ul real dacă suntem în spatele unui proxy
var forwardedFor = context.Request.Headers["X-Forwarded-For"].FirstOrDefault();
if (!string.IsNullOrEmpty(forwardedFor))
return forwardedFor.Split(',')[0].Trim();
return context.Connection.RemoteIpAddress?.ToString();
}
}
3.2 Înregistrare în DI
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IAuditLogger, AuditLogger>();
3.3 Utilizare în controllers
[HttpPost("login")]
[AllowAnonymous]
public async Task<IActionResult> Login(
[FromBody] LoginDto dto,
[FromServices] IAuditLogger auditLogger)
{
var result = await _signInManager
.PasswordSignInAsync(dto.Email, dto.Password,
isPersistent: false, lockoutOnFailure: true);
if (result.Succeeded)
{
await auditLogger.LogAsync(
eventType: "auth.login.success",
action: "Login",
succeeded: true,
additionalData: new()
{
["email"] = dto.Email // email, nu parolă!
});
return Ok();
}
await auditLogger.LogAsync(
eventType: "auth.login.failed",
action: "Login",
succeeded: false,
failureReason: result.IsLockedOut ? "Account locked" : "Invalid credentials",
additionalData: new() { ["email"] = dto.Email });
return Unauthorized();
}
4. Audit logging automat cu middleware și Action Filters
Pentru operații CRUD pe entități, poți automatiza audit logging-ul cu un Action Filter — fără să adaugi cod în fiecare controller.
public class AuditActionFilter(IAuditLogger auditLogger) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
var executedContext = await next();
// Logăm doar dacă există atribut [Audit] pe action/controller
var auditAttr = context.ActionDescriptor
.EndpointMetadata
.OfType<AuditAttribute>()
.FirstOrDefault();
if (auditAttr is null) return;
var succeeded = executedContext.Exception is null
&& executedContext.HttpContext.Response.StatusCode < 400;
await auditLogger.LogAsync(
eventType: auditAttr.EventType,
action: auditAttr.Action,
succeeded: succeeded,
resourceType: auditAttr.ResourceType,
failureReason: succeeded ? null :
$"HTTP {executedContext.HttpContext.Response.StatusCode}");
}
}
// Atribut custom
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public class AuditAttribute(
string eventType,
string action,
string? resourceType = null) : Attribute
{
public string EventType { get; } = eventType;
public string Action { get; } = action;
public string? ResourceType { get; } = resourceType;
}
// Înregistrare globală
builder.Services.AddControllers(options =>
{
options.Filters.Add<AuditActionFilter>();
});
// Utilizare
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
[Audit("admin.invoice.deleted", "DeleteInvoice", "Invoice")]
public async Task<IActionResult> Delete(int id)
{
await _invoiceService.DeleteAsync(id);
return NoContent();
}
5. Serilog — structured logging pentru audit
Serilog este cel mai popular logger în ecosistemul .NET pentru structured logging. Îl configurezi să trimită audit log-urile în mai multe destinații simultan.
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Serilog.Sinks.AzureCosmosDB // opțional, pentru Cosmos DB
dotnet add package Serilog.Enrichers.Environment
dotnet add package Serilog.Enrichers.Thread
// Program.cs
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.Enrich.FromLogContext()
.Enrich.WithEnvironmentName()
.Enrich.WithThreadId()
.Enrich.WithProperty("Application", "LudoProgramming")
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
.WriteTo.ApplicationInsights(
builder.Configuration["ApplicationInsights:ConnectionString"],
TelemetryConverter.Traces)
.CreateLogger();
builder.Host.UseSerilog();
Filtru dedicat pentru audit events
Separă audit log-urile de log-urile operaționale — le vrei în destinații diferite cu retenție diferită:
Log.Logger = new LoggerConfiguration()
.WriteTo.Logger(lc => lc
// Log-uri operaționale — retenție 30 zile
.Filter.ByExcluding(e =>
e.MessageTemplate.Text.StartsWith("[AUDIT]"))
.WriteTo.Console()
.WriteTo.ApplicationInsights(connectionString,
TelemetryConverter.Traces))
.WriteTo.Logger(lc => lc
// Audit log-uri — retenție 1 an (conformitate GDPR)
.Filter.ByIncludingOnly(e =>
e.MessageTemplate.Text.StartsWith("[AUDIT]"))
.WriteTo.ApplicationInsights(connectionString,
TelemetryConverter.Events) // ca Events, nu Traces
)
.CreateLogger();
6. Audit log în baza de date — trail persistent
Pentru conformitate GDPR și investigații forensice, audit log-urile trebuie stocate persistent, separat de log-urile operaționale. Cosmos DB e o alegere excelentă — append-only natural, scalabil, și deja în infrastructura LudoProgramming.
// Model Cosmos DB pentru audit
public class AuditEventDocument : AuditEvent
{
[JsonProperty("id")]
public string Id { get; init; } = Guid.NewGuid().ToString();
// Partition key: "userId" sau "tenantId" pentru queries eficiente
[JsonProperty("partitionKey")]
public string PartitionKey { get; init; } = default!;
// TTL: Cosmos DB poate șterge automat după perioada de retenție
[JsonProperty("ttl")]
public int? Ttl { get; init; } // secunde; -1 = nu expira
}
// Repository
public class CosmosAuditRepository(CosmosClient cosmosClient)
{
private readonly Container _container = cosmosClient
.GetContainer("AuditDb", "AuditEvents");
public async Task AppendAsync(AuditEvent auditEvent)
{
var doc = new AuditEventDocument
{
EventType = auditEvent.EventType,
Action = auditEvent.Action,
Category = auditEvent.Category,
Severity = auditEvent.Severity,
UserId = auditEvent.UserId,
UserName = auditEvent.UserName,
IpAddress = auditEvent.IpAddress,
ResourceType = auditEvent.ResourceType,
ResourceId = auditEvent.ResourceId,
Succeeded = auditEvent.Succeeded,
FailureReason = auditEvent.FailureReason,
CorrelationId = auditEvent.CorrelationId,
Timestamp = auditEvent.Timestamp,
AdditionalData = auditEvent.AdditionalData,
PartitionKey = auditEvent.UserId ?? "anonymous",
// Retenție: 1 an în secunde (GDPR)
Ttl = (int)TimeSpan.FromDays(365).TotalSeconds
};
await _container.CreateItemAsync(
doc, new PartitionKey(doc.PartitionKey));
}
}
Audit log-urile nu se modifică și nu se șterg manual. Append-only este o proprietate esențială pentru integritatea unui audit trail. Dacă ai nevoie să anonimizezi date din motive GDPR, suprascrie câmpurile
UserId,UserName,UserEmailcu un hash sau cu„[anonymized]”— dar păstrează evenimentul.
7. Security Events — detecție automată de atacuri
Dincolo de înregistrarea evenimentelor, vrei să detectezi pattern-uri care indică un atac în curs. Câteva scenarii comune:
7.1 Brute-force detection
public class SecurityEventDetector(
IAuditLogger auditLogger,
IDistributedCache cache)
{
// Detectare brute-force: X login-uri eșuate de la același IP în Y minute
public async Task<bool> IsLoginBruteForceAsync(
string ipAddress,
int maxFailures = 10,
int windowMinutes = 15)
{
var key = $"login-failures:{ipAddress}";
var cached = await cache.GetStringAsync(key);
var count = cached is null ? 0 : int.Parse(cached);
if (count >= maxFailures)
{
await auditLogger.LogAsync(
eventType: "security.bruteforce.detected",
action: "BruteForceDetected",
succeeded: false,
failureReason: $"{count} login failures in {windowMinutes} minutes",
additionalData: new() { ["ip"] = ipAddress, ["count"] = count });
return true;
}
// Incrementăm contorul
await cache.SetStringAsync(
key, (count + 1).ToString(),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow =
TimeSpan.FromMinutes(windowMinutes)
});
return false;
}
}
7.2 Acces neobișnuit — ore sau locații noi
// Loghează și alertează când un utilizator se autentifică
// dintr-o țară nouă sau la o oră neobișnuită
public async Task LogLoginWithContextAsync(
string userId,
string ipAddress,
DateTime loginTime)
{
var isUnusualHour = loginTime.Hour < 6 || loginTime.Hour > 22;
var severity = isUnusualHour ? "Warning" : "Info";
await auditLogger.LogAsync(new AuditEvent
{
EventType = isUnusualHour
? "auth.login.unusual-time"
: "auth.login.success",
Action = "Login",
Severity = severity,
UserId = userId,
IpAddress = ipAddress,
Succeeded = true,
AdditionalData = new()
{
["hour"] = loginTime.Hour,
["isUnusualTime"] = isUnusualHour
}
});
}
8. Alertare în Azure Monitor și Application Insights
Log-urile fără alertare sunt ca un sistem de alarmă fără sunet. Configurezi alerte automate în Azure Monitor pe baza query-urilor Kusto (KQL):
8.1 Alertă pentru brute-force
// KQL Query pentru Azure Monitor Alert
// Alertă dacă mai mult de 20 de login-uri eșuate în 5 minute de la același IP
traces
| where message startswith "[AUDIT]"
| where message contains "auth.login.failed"
| extend ip = tostring(customDimensions.IpAddress)
| summarize failureCount = count() by ip, bin(timestamp, 5m)
| where failureCount > 20
| project timestamp, ip, failureCount
8.2 Alertă pentru acces administrativ neobișnuit
// Alertă dacă un non-admin accesează endpoint-uri de admin
traces
| where message startswith "[AUDIT]"
| where customDimensions.EventType startswith "admin."
| where customDimensions.Succeeded == "False"
| where customDimensions.FailureReason contains "403"
| project timestamp,
userId = customDimensions.UserId,
path = customDimensions.RequestPath,
ip = customDimensions.IpAddress
| order by timestamp desc
8.3 Configurare alertă în portal Azure
- Azure Monitor → Alerts → Create alert rule
- Scope: Log Analytics Workspace sau Application Insights
- Condition: Custom log search (KQL de mai sus)
- Threshold: Greater than 0 results
- Action Group: Email / SMS / Teams webhook / PagerDuty
- Frequency: Every 5 minutes, Lookback: 15 minutes
9. GDPR și retenția audit log-urilor
Audit log-urile conțin date personale (userId, email, IP) și sunt supuse GDPR. Câteva reguli practice:
Retenție
- Audit log-uri de securitate: minim 1 an (recomandat 3 ani pentru investigații forensice)
- Log-uri operaționale: 30-90 zile
- Log-uri de acces la date medicale/financiare: conform reglementărilor specifice (ex: 10 ani pentru date fiscale în România)
Dreptul la ștergere — cum îl aplici fără a compromite audit trail
// La cererea utilizatorului de ștergere a datelor (GDPR Art. 17),
// anonimizezi datele personale din audit log, dar păstrezi evenimentul
public async Task AnonymizeUserAuditDataAsync(string userId)
{
var anonymizedId = $"[deleted-{Guid.NewGuid():N}]";
// Patch pe toate documentele din Cosmos DB cu userId-ul dat
var query = new QueryDefinition(
"SELECT * FROM c WHERE c.userId = @userId")
.WithParameter("@userId", userId);
var iterator = _container.GetItemQueryIterator<AuditEventDocument>(query);
while (iterator.HasMoreResults)
{
var page = await iterator.ReadNextAsync();
foreach (var doc in page)
{
var patches = new[]
{
PatchOperation.Set("/UserId", anonymizedId),
PatchOperation.Set("/UserName", "[deleted]"),
PatchOperation.Set("/UserEmail", "[deleted]")
// IpAddress și UserAgent pot rămâne — nu identifică persoana singure
};
await _container.PatchItemAsync<AuditEventDocument>(
doc.Id, new PartitionKey(doc.PartitionKey), patches);
}
}
}
10. Testare audit logging
[TestFixture]
public class AuditLoggerTests
{
private IAuditLogger _auditLogger = default!;
private ILogger<AuditLogger> _mockLogger = default!;
[SetUp]
public void Setup()
{
_mockLogger = Substitute.For<ILogger<AuditLogger>>();
var accessor = Substitute.For<IHttpContextAccessor>();
_auditLogger = new AuditLogger(accessor, _mockLogger);
}
[Test]
public async Task LogAsync_FailedLogin_LogsWarningWithDetails()
{
await _auditLogger.LogAsync(
eventType: "auth.login.failed",
action: "Login",
succeeded: false,
failureReason: "Invalid credentials");
_mockLogger.Received(1).Log(
LogLevel.Information,
Arg.Any<EventId>(),
Arg.Is<object>(o => o.ToString()!.Contains("auth.login.failed")),
null,
Arg.Any<Func<object, Exception?, string>>());
}
[Test]
public async Task LogAsync_NeverLogs_SensitiveData()
{
await _auditLogger.LogAsync(
eventType: "auth.login.failed",
action: "Login",
succeeded: false,
additionalData: new()
{
// Simulăm un bug unde s-ar putea loga parola accidental
["email"] = "user@test.com",
["password"] = "SHOULD-NOT-APPEAR"
});
// Verificăm că parola nu apare în niciun log message
_mockLogger.ReceivedCalls()
.Select(c => c.GetArguments()[2]?.ToString() ?? "")
.ToList()
.ForEach(msg =>
Assert.That(msg,
Does.Not.Contain("SHOULD-NOT-APPEAR")));
}
}
11. Checklist de producție
- ✅ Audit log-uri separate de log-uri operaționale — retenție diferită
- ✅ Structura
AuditEventconsistentă cuEventTypeierarhic - ✅ Zero date sensibile în log-uri: fără parole, tokens, carduri
- ✅ Toate evenimentele de autentificare logate (success + failure)
- ✅ Toate operațiile administrative logate
- ✅
CorrelationIdinclus pentru corelarea evenimentelor dintr-un request - ✅ IP real din
X-Forwarded-Forcând aplicația e în spatele unui proxy - ✅ Audit log-uri stocate persistent (Cosmos DB, Azure Table Storage) cu TTL configurat
- ✅ Alerte în Azure Monitor pentru brute-force și acces administrativ neautorizat
- ✅ Procedură de anonimizare GDPR implementată și testată
- ✅ Audit log-urile sunt append-only — nicio operație de ștergere sau modificare
- ✅ Teste care verifică că datele sensibile nu apar în log-uri
Concluzie — finalul seriei
Audit logging este ultimul strat dintr-o strategie de securitate completă — și primul instrument pe care îl folosești când ceva merge prost. Fără el, un incident de securitate e un mister. Cu el, e o investigație cu date concrete: cine, ce, când, de unde.
Aceasta este și concluzia seriei de 15 articole despre securitate în ASP.NET Core. Am parcurs drumul complet: de la principii (Security by Design), prin autentificare și autorizare (JWT, OAuth2, certificate, IdentityServer, Keycloak), la protecție împotriva atacurilor (CSRF, XSS, Injection, Rate Limiting) și, în final, vizibilitate (Audit Logging).
Securitatea nu e o caracteristică adăugată la final — e o proprietate a întregului sistem, construită strat cu strat, din prima zi de development.