AutoMate Technisch Help

Audit Logging

Deze pagina beschrijft de technische architectuur van het audit logging systeem in AutoMate API: hoe events worden geschreven naar Seq in CLEF-formaat, hoe de ambient process-context via AsyncLocal<T> stroomt, hoe records worden teruggelezen, en hoe nieuwe audit types worden toegevoegd.

Architectuuroverzicht

Het audit logging systeem bestaat uit twee lagen:

Schrijven: Service → IAuditLog → AuditLogService → IAuditLogSink (SeqAuditLogSink) └─ POST /api/events/raw → Seq └─ ConcurrentDictionary → in-memory fallback Lezen: IAuditLog.GetBatchAsync → SeqAuditLogSink.GetBatchInternalAsync └─ GET /api/events?filter=Id='{id}'... → Seq └─ fallback → in-memory buffer

Laag 1 — Schrijven via IAuditLog/SeqAuditLogSink

IAuditLog is de centrale write-interface. De implementatie SeqAuditLogSink schrijft elk audit event rechtstreeks naar Seq via POST /api/events/raw in CLEF-formaat (Compact Log Event Format). Tegelijk wordt het event in een ConcurrentDictionary in-memory opgeslagen als niet-duurzame fallback voor same-process reads.

Laag 2 — Lezen via batch-ophaling

GetBatchAsync en StreamBatchAsync bevragen Seq via GET /api/events met een filter op (tenantId, correlationId). Als Seq niet bereikbaar is, valt de sink terug op de in-memory buffer.

Dependency registratie

// Program.cs — al aanwezig in AutoMate.API builder.Services.AddAutomateAuditLog(builder.Configuration);

Dit registreert:

  • IAuditLog (scoped) — de provider-agnostische write/read interface

  • Keyed singletons: "seq" (SeqAuditLogSink) en "azure-table" (AzureTableAuditLogSink)

  • ISeqStatusService (singleton) — bevraagt Seq voor dashboard module-status

  • HttpClient "SeqAudit" met 15 seconden timeout (geen retry — trage queries worden anders verergerd)

CLEF event structuur

Elk audit write door SeqAuditLogSink produceert één CLEF-event. De properties op root-niveau zijn:

Property

Type

Altijd aanwezig

Betekenis

@t

string

ja

ISO-8601 timestamp (UTC)

@l

string

ja

Altijd "Information"

@mt

string

ja

Message template: "{Type}: {Message}"

EventKind

string

ja

Altijd "Audit"sentinel

TenantId

string

ja

Tenant UUID als string (voor filter equality)

Id

string

ja

Correlatie/batch-id — groepeert alle stappen van één operatie

Type

string

ja

Bijv. "IAM.Employee.Created"

Module

string

ja

Eerste segment van Type, bijv. "IAM"

Message

string

ja

JSON-payload met action, actor, changes, entityDisplayName

Process

string

nee

Ambient process-naam, bijv. "IamSync", "AutoLeave"; null bij form-acties

EntityDisplayName

string

nee

Uitgeflattend uit Message JSON indien aanwezig

Action

string

nee

Uitgeflattend uit Message JSON indien aanwezig

Waarom EventKind = "Audit"?

Dit is de sentinel die zakelijke audit events onderscheidt van reguliere Serilog telemetrie (MSAL token-acquisitie, HTTP client logs, library ruis). Reguliere Serilog events hebben deze property nooit. Alle Seq-queries in het status dashboard gebruiken EventKind = 'Audit' als anker zodat telemetrie-ruis nooit in audit-resultaten landt.

Waarom Module als top-level property?

Module is afgeleid van het eerste segment van Type ("IAM.Employee.Created""IAM") en wordt als geïndexeerde Seq-property opgeslagen. Dit maakt de filter Module = 'IAM' mogelijk zonder een Type like 'IAM%' prefix-match, wat aanzienlijk efficiënter is in Seq.

Waarom EntityDisplayName en Action uitflattenen?

De Message-veld bevat een JSON-payload. Als SeqStatusService deze informatie nodig heeft voor het dashboard, zou het client-side JSON-parsing vereisen op elk event. Door EntityDisplayName en Action als top-level CLEF-properties op te nemen kan Seq ze direct indexeren en kan de status service ze zonder verdere parsing lezen.

Voorbeeld CLEF-event (IAM sync)

{ "@t": "2025-06-01T08:15:22.000Z", "@l": "Information", "@mt": "{Type}: {Message}", "EventKind": "Audit", "TenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Id": "iamsync-batch-2025-06-01", "Type": "IAM.Employee.Created", "Module": "IAM", "Message": "{\"Action\":\"Created\",\"EntityDisplayName\":\"jan.de.vries@contoso.nl\",\"Actor\":\"IamSync\"}", "Process": "IamSync", "EntityDisplayName": "jan.de.vries@contoso.nl", "Action": "Created" }

Voorbeeld CLEF-event (form-actie, geen Process)

{ "@t": "2025-06-01T09:30:00.000Z", "@l": "Information", "@mt": "{Type}: {Message}", "EventKind": "Audit", "TenantId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "Id": "employee-offboard-op-456", "Type": "IAM.Employee.ImmediateOffboard", "Module": "IAM", "Message": "{\"Action\":\"ImmediateOffboard\",\"EntityDisplayName\":\"piet.jansen@contoso.nl\",\"Actor\":\"beheerder@contoso.nl\"}", "EntityDisplayName": "piet.jansen@contoso.nl", "Action": "ImmediateOffboard" }

AuditContext — ambient process propagation

AuditContext in API/AutoMate.API.Shared/Conventions/AuditContext.cs slaat de actieve process-naam op via AsyncLocal<string?>. Hierdoor stroomt de process-context automatisch mee door alle async/await-chains zonder dat callsites op IAuditLog.WriteAsync aangepast hoeven te worden.

public static class AuditContext { private static readonly AsyncLocal<string?> _process = new(); public static string? Process => _process.Value; internal static IDisposable PushProcess(string process) { var previous = _process.Value; _process.Value = process; return new RestoreAction(() => _process.Value = previous); } }

Hoe LogJobContextAttribute dit aanzet

LogJobContextAttribute is een Hangfire job filter die bij aanvang van een job zowel Serilog's LogContext als AuditContext vult:

[LogJobContext(LoggingConstants.Modules.Sync, LoggingConstants.Features.Employees, LoggingConstants.Processes.IamSync)] internal class IamSyncJob { ... }

Bij OnPerforming roept de filter AuditContext.PushProcess(process) aan. SeqAuditLogSink leest AuditContext.Process op het moment van schrijven en stamt het CLEF-event ermee.

Hangfire start IamSyncJob └─ LogJobContextAttribute.OnPerforming ├─ LogContext.PushProperty("Process", "IamSync") → Serilog telemetrie └─ AuditContext.PushProcess("IamSync") → audit CLEF events ... job-logica schrijft audit events ... Elk WriteAsync leest AuditContext.Process → "IamSync" → CLEF event krijgt Process = "IamSync" └─ LogJobContextAttribute.OnPerformed → dispose → Process hersteld naar null

Onderscheid tussen triggers in Seq

Filter

Betekenis

EventKind = 'Audit' and Process = 'IamSync'

Events geschreven door IAM sync job

EventKind = 'Audit' and Process = 'AutoLeave'

Events geschreven door AutoLeave check job

EventKind = 'Audit' and Process is null

Events geschreven via de AutoMate form/API (door een gebruiker)

AuditLogRecord model

public sealed class AuditLogRecord { public Guid TenantId { get; init; } public string Id { get; init; } // correlatie/batch-id public string Type { get; init; } // bijv. "IAM.Employee.Created" public string? Message { get; init; } // JSON-payload public DateTimeOffset TimestampUtc { get; init; } }

Het Id-veld groepeert alle stappen van één operatie (batch). Een IAM sync batch schrijft meerdere records met hetzelfde Id, één per verwerkte employee of stap. GetBatchAsync haalt alle records voor een (tenantId, id) op in chronologische volgorde.

Lezen en downloaden van audit records

GetBatchAsync / StreamBatchAsync

Filter gebruikt bij Seq-query: Id = '{id}' and TenantId = '{tenantId}'

Terugvalstrategie:

  1. Probeer Seq (duurzaam, binnen QueryWindowDays terugkijkvenster)

  2. Als Seq niet bereikbaar of geen resultaten: in-memory buffer (ConcurrentDictionary)

DownloadAuditLogsCommandHandler

Verwerkt auditlog://-URIs zoals auditlog://seq/{tenantId}/{correlationId}. Haalt de batch op via IAuditLog.GetBatchAsync en rendert de records als een leesbaar tekstbestand voor download.

URI-formaat:

  • Seq: auditlog://seq/{tenantId}/{correlationId}

  • AzureTable: auditlog://azure-table/{tenantId}/{tableName}/{correlationId}

Het correlationId is altijd het laatste segment van de URI.

Audit types per module

IAM — EmployeeAuditConstants

Locatie: API/Src/AutoMate.API/Features/IAM/Employees/EmployeeAuditConstants.cs

Type

Trigger

IAM.Employee.Created

Medewerker aangemaakt (sync of form)

IAM.Employee.Updated

Medewerker bijgewerkt (sync of form)

IAM.Employee.Deleted

Medewerker verwijderd

IAM.Employee.PhotoUploaded

Profielfoto geüpload

IAM.Employee.PasswordReset

Wachtwoord gereset

IAM.Employee.MfaReset

MFA gereset

IAM.Employee.ImmediateOffboard

Direct offboarden (handmatig)

IAM.Employee.ScheduledOffboard

Offboard gepland

IAM.Employee.Activate

Account geactiveerd

Events met Process = 'AutoLeave' (AutoLeave check job) schrijven eveneens naar IAM-audit types — typisch IAM.Employee.Deleted of een vergelijkbaar type voor auto-uitdiensttreding.

OrgModel — OrgModelAuditConstants

Locatie: API/Src/AutoMate.API/Features/UserTypes/OrgModel/OrgModelAuditConstants.cs

Type

Trigger

OrgModel.Attribute.Replaced

Attribuutwaarde vervangen via OrgModel resolutie

OrgModel.Attribute.Cleared

Attribuutwaarde gewist via OrgModel resolutie

Centrale log-ID voor de OrgModel-overzichtspagina: "OrgModel.AttributeChanges".

Seq query strategie

Alle Seq-queries in zowel SeqAuditLogSink als SeqStatusService volgen deze aanpak:

Correcte query parameters

Parameter

Waarde

Reden

filter

URL-encoded Seq filter-expressie

Doelgerichte query

fromDateUtc

ISO-8601 UTC timestamp

Tijdvenster start (gebruik fromDateUtc, niet fromUtc)

toDateUtc

ISO-8601 UTC timestamp

Tijdvenster einde (gebruik toDateUtc, niet toUtc)

count

N

Maximum events te retourneren

shortCircuitAfter

N (zelfde als count)

Seq stopt scannen zodra N events gevonden zijn

render

true

Seq rendert RenderedMessage server-side

shortCircuitAfter is kritisch voor performance: zonder dit parameter blijft Seq het volledige tijdvenster scannen ook als het maximum al bereikt is.

render=true wordt alleen toegepast op status-queries (niet op batch-ophaling) zodat het dashboard weergave-klare berichten ontvangt zonder extra client-side rendering.

API key authenticatie

De API key wordt verzonden via de X-Seq-ApiKey HTTP header — nooit via query string.

req.Headers.Add("X-Seq-ApiKey", apiKey);

Voorbeeld query URL

GET http://logs.automate365.cloud:5341/api/events ?filter=EventKind%20%3D%20'Audit'%20and%20Module%20%3D%20'IAM'%20and%20TenantId%20%3D%20'aaaa...' &fromDateUtc=2025-06-01T00%3A00%3A00Z &toDateUtc=2025-06-01T23%3A59%3A59Z &count=20 &shortCircuitAfter=20 &render=true

HttpClient configuratie

De "SeqAudit" HttpClient heeft een timeout van 15 seconden en geen retry-handler. Retries worden bewust weggelaten: als een Seq-query traag is, maakt een retry de situatie erger. Mislukte writes worden door de in-memory buffer opgevangen; mislukte status-queries retourneren ModuleHealthStatus.Unknown.

Configuratie

{ "AuditLog": { "DefaultProvider": "Seq", "Seq": { "ServerUrl": "http://logs.automate365.cloud:5341", "ApiKey": "...", "QueryWindowDays": 7, "MaxEvents": 1000 } } }

Sleutel

Standaard

Betekenis

DefaultProvider

"Seq"

Actieve audit sink. Ondersteunde waarden: "Seq", "AzureTable"

Seq.ServerUrl

Basis-URL van de Seq-instantie (inclusief poort)

Seq.ApiKey

Seq API key; verzonden als X-Seq-ApiKey header

Seq.QueryWindowDays

7

Hoever terug Seq kijkt bij GetBatchAsync

Seq.MaxEvents

1000

Maximum events bij GetBatchAsync

In lokale ontwikkeling met Aspire: als AuditLog:Seq:ServerUrl niet is geconfigureerd valt de registratie automatisch terug op ConnectionStrings:seq (door Aspire geïnjecteerd via .WithReference(seq) in de AppHost).

Schrijven van audit events

public class EmployeeService { private readonly IAuditLog _audit; private readonly IMultiTenantService _tenants; public EmployeeService(IAuditLog audit, IMultiTenantService tenants) { _audit = audit; _tenants = tenants; } public async Task CreateEmployeeAsync(string correlationId, Employee employee, CancellationToken ct) { var tenantId = _tenants.GetTenantId(); // Voer de operatie uit ... var payload = JsonSerializer.Serialize(new { Action = EmployeeAuditConstants.Actions.Created, EntityDisplayName = employee.UserPrincipalName, Actor = "IamSync" }); await _audit.WriteAsync(tenantId, correlationId, EmployeeAuditConstants.Types.Created, payload, ct); } }

De tenantId wordt altijd opgehaald via IMultiTenantService.GetTenantId() uit de X-Tenant-Id HTTP header — nooit als request body parameter.

Nieuwe audit types toevoegen

  1. Definieer een constants class in de desbetreffende feature-folder:

internal static class MyFeatureAuditConstants { public const string TypePrefix = "MyModule.MyEntity."; public static class Types { public const string Created = "MyModule.MyEntity.Created"; public const string Updated = "MyModule.MyEntity.Updated"; } public static class Actions { public const string Created = "Created"; public const string Updated = "Updated"; } }
  1. Schrijf het event via IAuditLog.WriteAsync met een JSON-payload die minimaal Action en EntityDisplayName bevat:

var payload = JsonSerializer.Serialize(new { Action = MyFeatureAuditConstants.Actions.Created, EntityDisplayName = entity.DisplayName, Actor = actorUpn }); await _audit.WriteAsync(tenantId, operationId, MyFeatureAuditConstants.Types.Created, payload, ct);
  1. Module is automatisch: SeqAuditLogSink leidt Module af uit het eerste segment van Type. "MyModule.MyEntity.Created" produceert Module = "MyModule".

  2. Process is automatisch: als het event wordt geschreven vanuit een Hangfire job met [LogJobContext(...)], wordt Process automatisch gestampt via AuditContext. Bij een API endpoint-aanroep blijft Process null.

  3. Optioneel: voeg dashboard signals toe in StatusSignals.cs als het nieuwe type zichtbaar moet zijn op het status dashboard. Gebruik altijd EventKind = 'Audit' als filter-anker.

Aandachtspunten

  • De in-memory ConcurrentDictionary fallback is niet duurzaam: bij herstart van de API gaan niet-naar-Seq geschreven records verloren. Zorg dat Seq.ServerUrl altijd correct geconfigureerd is voor productie.

  • Pas QueryWindowDays aan als batches langer dan 7 dagen terugkijken. Dit verlengd ook de Seq-scantijd.

  • Gebruik EventKind = 'Audit' als primair filter-anker in alle dashboardqueries — nooit @l = 'Information' als enige onderscheid, want reguliere Serilog events zijn ook Information-level.

  • Tenant ID wordt altijd via IMultiTenantService.GetTenantId() opgehaald uit de X-Tenant-Id header — nooit als request body parameter. Dit geldt voor alle AutoMate.API endpoints.

Last modified: 05 April 2026