Audit log
Blouppy.Audit keeps, for each organization, a record of its administration: who did
what, when, through which channel and from which address. Owners and administrators read
it and export it. An entry is written from an integration event the producer publishes in
the same transaction as its change, so an entry exists if and only if the change
committed.
What it does
For an owner or administrator, it is Organization → Audit log
(features/organization/organization-settings/organization-audit-log/): a list, newest
first, filtered by actor, event type, channel and date range. Each entry opens on its
detail — the changes before and after, the IP address and the user agent of the request.
Export downloads exactly what the filters select, as a CSV. Members and viewers get a
403 and do not see the menu entry. Reading the log is not recorded; exporting it is.
The vocabulary is AuditEventType, stored by its code:
| Family | Event types |
|---|---|
| Members and invitations | invitation.created, invitation.revoked, invitation.accepted, invitation.declined, member.role_changed, member.removed, member.erased, ownership.transferred |
| Settings | settings.profile_changed, settings.api_mcp_access_changed, settings.builtin_ai_changed |
| Tokens and connectors | token.created, token.revoked, connector.authorized, connector.revoked |
| AI key | ai_key.saved, ai_key.deleted |
| Webhooks | webhook.created, webhook.updated, webhook.secret_rotated, webhook.deleted |
| Plan | billing.plan_change_requested, billing.plan_changed |
| Deletions | workspace.deleted |
| The log | audit_log.exported |
Some writes are deliberately left out — creating or renaming a workspace, a person's own
notifications and settings, testing a webhook, an AI call (its ledger is
ai_usage_records, see AI), and deleting the organization, whose log goes
with it. Every one of those exclusions is written down with its reason (see
Extending it).
For the developer, the log asks nothing at the call site: a service changes an
aggregate, the aggregate raises a domain event, and a handler in the producing domain
publishes the fact. Only Blouppy.Audit knows what an entry looks like.
How it works
| Project | Holds |
|---|---|
Blouppy.Audit.Models | AuditEntry, AuditEventType, AuditTargetType, AuditChange, AuditRules |
Blouppy.Audit.Abstractions | IAuditLogService: PurgeExpiredAsync, RecordExportAsync |
Blouppy.Audit | One handler per auditable event in IntegrationEventHandlers/<Family>/, AuditEntryRecorder; AddAuditModule() |
Blouppy.Infrastructure.Messaging.Abstractions/Audit/ | IAuditableIntegrationEvent, AuditActor |
Blouppy.Infrastructure.Database/Audit/ | The audit_entries table, its repository, the read side's query repository |
Blouppy.Web/Controllers/Api/Organizations/AuditLog/ | AuditLogController |
The contract on the bus. Every recorded fact implements IAuditableIntegrationEvent:
public interface IAuditableIntegrationEvent : IIntegrationEvent
{
AuditActor Actor { get; }
/// <summary>Null when the fact is a user's rather than an organization's, fanned out to their organizations.</summary>
OrganizationId? OrganizationId { get; }
DateTimeOffset OccurredAt { get; }
}
AuditActor(UserId, ActorChannel, IpAddress, UserAgent) is captured where the change is
made, with AuditActor.Of(ICurrentUserService): the consumer runs off the request, where
there is no current user. Two fixed actors exist: AuditActor.System
(UserId.ExternalIntegration, e.g. the payment provider confirming a plan) and
AuditActor.Erasure (UserId.Deleted, for account erasure). The
channel is App (the browser), Api (a personal access token or a connector's token)
or Assistant.
Facts, not log entries. A producer publishes what happened —
OrganizationMemberRoleChangedIntegrationEvent with the role before and after, by name —
and never an audit format. Roles and plans travel as strings because Blouppy.Audit may
not reference another domain's models.
The handler and the recorder. A handler picks the event type, the target and an allow-list of changes; a property added to an event later stays out of the log until someone names it:
// Blouppy.Audit/IntegrationEventHandlers/Organizations/ (trimmed)
internal sealed class RecordWhenOrganizationMemberRoleChangedIntegrationEventHandler(IAuditEntryRecorder recorder)
: IIntegrationEventHandler<OrganizationMemberRoleChangedIntegrationEvent>, IRecordsAuditEvents
{
public static IReadOnlyCollection<AuditEventType> RecordedEventTypes { get; } = [AuditEventType.MemberRoleChanged];
public Task HandleAsync(OrganizationMemberRoleChangedIntegrationEvent @event, CancellationToken cancellationToken = default)
=> recorder.RecordAsync(
@event,
AuditEventType.MemberRoleChanged,
new AuditTarget(AuditTargetType.Member, @event.MemberId.Value),
[AuditChanges.Changed("role", @event.PreviousRole, @event.Role)],
cancellationToken);
}
AuditEntryRecorder adds the rest: the actor, channel and origin from the event, its
OccurredAt (so a late delivery still sorts where it happened), and the message id. A
unique index on (source_message_id, organization_id) makes a redelivery insert nothing.
A user's fact — a token, a connector — names no organization, and UsersOrganizations
records it in each organization the user belongs to when the event is consumed.
What an entry never holds. No secret reaches the bus: an invitation's token, a webhook
secret or header value, an AI key are not in any recorded change, and a webhook URL is
stripped by WebhookRules.UrlWithoutSecrets. A person is never a label: actors and
members are ids resolved through users_view at read time, so an erased one reads as
Deleted user. The one personal datum stored as text is an invitee's e-mail address.
Reading and exporting. Both routes are under
/api/v1/organizations/{organizationId}/audit-log and require Policies.Manageand
the App channel — a token cannot read a log of who used tokens. An unknown event-type
code is a 400. The read goes through IAuditLogApiQueryService (cursor-paged); GET …/export first publishes AuditLogExportedIntegrationEvent and commits, then streams the
CSV through AuditLogCsvGenerator, 1,000 rows at a time, with stable column codes
(occurred_at, event_type, actor_id, actor_name, channel, ip_address,
user_agent, target_type, target_id, target_label, changes), a BOM and every cell
passed through CsvCells.Neutralize.
Append-only. Only the module, its repository, BlouppyDbContext and
UserErasureRepository may depend on AuditEntry — AuditCoverageTests enforces it.
Erasure is the one rewrite: it changes who, never what.
Configuration
| Value | Where |
|---|---|
| Retention: 13 months, every plan | AuditRules.RetentionInMonths |
| Purge: once a day, 5,000 rows per statement | AuditEntryPurgeJob.Interval, AuditRules.PurgeBatchSize |
| User agent cut at 512 characters | RequestOrigin.MaxUserAgentLength |
AuditEntryPurgeJob (Quartz.NET, Blouppy.Infrastructure.Scheduling/Jobs/) calls
IAuditLogService.PurgeExpiredAsync, which deletes entries older than the retention in
batches until one comes back short. The store is clustered, so one replica runs it, and a
run missed while every replica was down happens once on the next start.
The recorded IP address comes from X-Forwarded-For, which the gateway sets and the
API trusts only from the networks in ForwardedHeaders:TrustedNetworks — loopback when
unset, which is the gateway under Aspire in development. X-Forwarded-Host is honoured
only for ForwardedHeaders:AllowedHosts. A deployment that does not configure the
gateway's network records the gateway's address: wrong, but never forged. The Docker
Compose deployment sets it from BLOUPPY_SUBNET (Deploy).
There is no switch and no entitlement: the log is on for every organization. The sample data seeded in development writes no entries; change a role or send an invitation to see the first ones a moment later.
Extending it
A new write on a driving port
The rule in CLAUDE.md: a write you add to an IXxxService needs a row in
AuditCoverage.cs.AuditCoverageTests scans every I…Service in a domain's or a
module's .Abstractions; a method is a write unless its name starts with Get, TryGet,
Count, List, Search, Find, Is, Has or Can followed by a capital (so
CancelAsync is a write). Each write needs one of:
// Blouppy.Architecture.UnitTests/Architecture/AuditCoverage.cs
["IWorkspacesService.TryDeleteAsync"] = new Audited(AuditEventType.WorkspaceDeleted),
["IWorkspacesService.TryAddAsync"] = new NotAudited(Reversible),
A row for a method that no longer exists fails too. NotAudited takes a reason, so the
decision is made in review rather than by omission.
A new auditable change
- Raise a domain event from the aggregate, only when something actually differs
(
OrganizationMember.Update(role)raises nothing for the same role). - Declare the integration event in
Blouppy.Infrastructure.Messaging.Abstractions/<Domain>/, implementingIAuditableIntegrationEvent, withAuditActor Actoras a positional parameter and nothing secret in it. - Publish it from a
PublishIntegrationEventWhen…DomainEventHandlerin the producing domain, withAuditActor.Of(currentUserService). Register its publisher and consumer inBlouppy.Infrastructure.Messaging(see Events and messaging). - Add the vocabulary: an
AuditEventTypewith a stable code, and anAuditTargetTypeif the target is new. - Record it: a
RecordWhen…IntegrationEventHandlerinBlouppy.Audit/IntegrationEventHandlers/<Family>/, implementingIRecordsAuditEvents, building its changes withAuditChanges(Set,Removed,Named,Changed,Toggled). - Mark the write
Audited(...)inAuditCoverage.cs. - Label it in the page:
audit-log-vocabulary.ts, andorganization.auditLoginen.jsonandfr.json. - If a change holds personal text, teach
UserErasureRepository.AnonymizeAuditEntriesAsyncto blank it, as it does forAuditChange.EmailField.
The build then holds it: every IAuditableIntegrationEvent must have a handler in
Blouppy.Audit, every handler must declare RecordedEventTypes, every AuditEventType
must be recorded by some handler, and an Audited row may only name a recorded type.
Limits
- Eventual, not synchronous. The entry lands a moment after the change, once the outbox is flushed and the consumer runs.
- Organizations only. The identity host's own writes — sign-ins, passwords, two-factor —
are not recorded; its
AuditCoverage(inBlouppy.Identity.UnitTests/Architecture/) lists them, and the single sign-on and SCIM configuration rows there are still to be decided. - No longer than 13 months, on any plan. Export the CSV to keep more.
- Not in the person's data export. Erasure anonymizes the person in the log instead (Privacy).