Audit Events
Wallow records security-relevant authentication events to a dedicated auth_audit PostgreSQL schema. These records are append-only and written independently of the main module schemas, so a failure to write an audit entry never fails the originating request.
Table Schema
Events are stored in auth_audit.auth_audit_entries.
Column names are PascalCase in the database. EF Core maps the entity's property names straight through — nothing in this repo applies a snake_case naming convention — so every identifier must be double-quoted in hand-written SQL.
| Column | Type | Nullable | Description |
|---|---|---|---|
"Id" |
uuid |
No | Primary key, generated per event |
"EventType" |
text |
No | String identifier for the event (see below) |
"UserId" |
uuid |
Yes | The user the event is about; null for the events that have no person at all, such as ClientAuthenticationFailed |
"ActorId" |
uuid |
Yes | Who caused the event, when that is somebody other than the subject. Null for every authentication event — nobody logs in on another person's behalf |
"TenantId" |
uuid |
Yes | The tenant the event happened inside, or null when it happened outside every organization |
"IpAddress" |
text |
Yes | Client IP address, when available |
"UserAgent" |
text |
Yes | HTTP User-Agent header, when available |
"ClientId" |
text |
Yes | The OAuth client the event is about, for the client lifecycle events below; null for every other event |
"Reason" |
text |
Yes | The operator's stated reason, for the platform-suspension events that carry one; null for everything else |
"OccurredAt" |
timestamp with time zone |
No | UTC timestamp; defaults to now() at insert |
The table is created by the InitialCreate EF Core migration in Wallow.Shared.Infrastructure.Core
(Migrations/AuthAudit/).
Event Types
The "EventType" column uses plain string values. The following events are recorded by default.
"EventType" |
Trigger | IP recorded |
|---|---|---|
LoginSucceeded |
A user successfully authenticates | Yes |
LoginFailed |
A login attempt is rejected (wrong password, unknown user, etc.) | Yes |
AccountLockedOut |
A user account is locked after repeated failed login attempts | Yes |
MfaLockedOut |
A user is locked out after repeated MFA failures | No |
Membership<Transition> |
Somebody's membership of an organization changed state (see below) | No |
ClientRegistered |
Somebody registered an application or service account for an organization (see below) | Yes |
ClientSecretRotated |
Somebody rotated a registered client's secret (see below) | Yes |
ClientSuspended |
Somebody suspended a registered client, ending every token it held (see below) | Yes |
ClientReinstated |
Somebody reinstated a suspended client (see below) | Yes |
ClientDeleted |
Somebody deleted a registered client along with its tokens, consents and branding (see below) | Yes |
ClientBrandingUpdated |
Somebody replaced a client's branding or removed its logo (see below) | Yes |
ClientSuspendedByPlatform |
A global admin placed the platform's suspension on a registered client (see below) | Yes |
ClientReinstatedByPlatform |
A global admin lifted a client's platform suspension (see below) | Yes |
ClientAuthenticationFailed |
A token request failed client authentication — a wrong or missing client secret, an unknown client, or any other invalid_client answer (see below) |
Yes |
OrganizationSuspendedByPlatform |
A global admin placed the platform's suspension on an organization (see below) | No |
OrganizationReinstatedByPlatform |
A global admin lifted an organization's platform suspension (see below) | No |
OrganizationDeleted |
Somebody deleted an organization, ending every membership and bound client with it | No |
Each event is written by AuthAuditEventHandlers in the Identity module, which subscribes to the corresponding Wolverine in-memory integration events published by the Identity domain — except ClientBrandingUpdatedEvent, which the Branding module publishes, and ClientAuthenticationFailed, which is not an integration event at all: the AuditInvalidClientTokenResponses OpenIddict handler observes every invalid_client token response on its way out.
"EventType" |
Source integration event |
|---|---|
LoginSucceeded |
UserLoginSucceededEvent |
LoginFailed |
UserLoginFailedEvent |
AccountLockedOut |
UserAccountLockedOutEvent |
MfaLockedOut |
UserMfaLockedOutEvent |
Membership<Transition> |
MembershipTransitionedEvent |
ClientRegistered |
ClientRegisteredEvent |
ClientSecretRotated |
ClientSecretRotatedEvent |
ClientSuspended |
ClientSuspendedEvent |
ClientReinstated |
ClientReinstatedEvent |
ClientDeleted |
ClientDeletedEvent |
ClientBrandingUpdated |
ClientBrandingUpdatedEvent |
ClientSuspendedByPlatform |
ClientSuspendedByPlatformEvent |
ClientReinstatedByPlatform |
ClientReinstatedByPlatformEvent |
ClientAuthenticationFailed |
— (AuditInvalidClientTokenResponses, an OpenIddict server handler) |
OrganizationSuspendedByPlatform |
OrganizationSuspendedByPlatformEvent |
OrganizationReinstatedByPlatform |
OrganizationReinstatedByPlatformEvent |
OrganizationDeleted |
OrganizationDeletedEvent |
Membership events
MembershipTransitionedEvent carries a Transition discriminator rather than shipping one record
per transition. The handler spells that discriminator into the event type — MembershipApproved,
MembershipSuspended, and so on — so a membership decision is queried exactly the same way every
other audited event is.
The fourteen MembershipTransition values are AccessRequested, Enrolled, Added, Approved,
Denied, DenialCleared, Suspended, Reinstated, RoleAssigned, RoleRemoved, Left,
Removed, OwnerMarked and OwnerUnmarked.
This handler writes "ActorId": it records who made the change, while "UserId"
records who it was made about. The two are equal for the transitions somebody performs on their own
membership — requesting access, enrolling, leaving — and that equality is the record, not an
omission. Apart from the client lifecycle, platform suspension and organization deletion events
below, every other audited event leaves "ActorId" null.
To read the whole family:
SELECT "EventType", "UserId", "ActorId", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "EventType" LIKE 'Membership%'
ORDER BY "OccurredAt" DESC;
Client lifecycle events
ClientRegistered, ClientSecretRotated, ClientSuspended, ClientReinstated,
ClientDeleted, ClientBrandingUpdated and the two client platform-suspension events below are
about a registered client rather than a person, so they are the only events that fill "ClientId". The person who did it stands in both "UserId" and "ActorId" — there is no separate
subject — and "TenantId" is the organization that owns the client. All of them carry the caller's
IP address when the request exposed one.
ClientSuspended means every token the client held was revoked and its realtime connections were
hung up; the client's configuration, branding and consents survive, and ClientReinstated puts it
back in service without asking anyone to consent again. ClientDeleted is the end of the record:
the client's tokens, consents and branding are gone with it, and registering the same name again
starts a fresh client with no consents. A deleted client's audit rows are all that remains of it,
which is why "ClientId" is a string rather than a foreign key.
ClientSecretRotatedEvent also says whether the rotation revoked the client's outstanding tokens;
that flag is logged by the Identity module but not stored in the audit row.
ClientBrandingUpdated records every write to the client's end-user-facing branding — a PUT of the
branding sub-resource or a logo removal alike. The event carries the resulting display name, which
Identity also copies onto the OpenIddict application, but the audit row stores only who changed the
branding and when; the branding itself lives in the Branding module.
To see a client's history:
SELECT "EventType", "ActorId", "IpAddress", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "ClientId" = '$client_id'
ORDER BY "OccurredAt" DESC;
Client authentication failures
ClientAuthenticationFailed records every invalid_client answer the token endpoint gives — a
wrong or missing secret, an unknown client_id, a suspended client's refusal. It is the one event
with no person at all: "UserId" and "ActorId" are both null, "ClientId" holds whatever
client_id the caller presented (which may name no registered client), and the caller's IP address
and User-Agent are taken from the HTTP request itself.
Each failure also ticks a per-client_id counter (Identity:InvalidClientLockout in
configuration, five failures in five minutes by default). A client that trips it is temporarily
rejected at the token endpoint — correct secret or not — with the same generic invalid_client
answer, for the configured lockout window. The lockout's own refusals are not recorded or counted;
only genuine authentication failures are, so the rows measure the caller's guesses, not the brake.
To see who has been guessing:
SELECT "ClientId", "IpAddress", COUNT(*) AS failures
FROM auth_audit.auth_audit_entries
WHERE "EventType" = 'ClientAuthenticationFailed'
AND "OccurredAt" >= now() - INTERVAL '24 hours'
GROUP BY "ClientId", "IpAddress"
ORDER BY failures DESC;
Platform suspension events
ClientSuspendedByPlatform, ClientReinstatedByPlatform, OrganizationSuspendedByPlatform and
OrganizationReinstatedByPlatform record the platform operator's own interventions — a global
admin acting above every organization role. "UserId" and "ActorId" both hold the operator, and
"TenantId" holds the affected organization (for the client events, the organization that owns the
client). The two …SuspendedByPlatform events carry the operator's stated reason in "Reason" —
the same reason the organization's admins read on the client or organization — while the
…ReinstatedByPlatform events leave it null: lifting needs no justification on the record, the
placement carries it.
A platform-suspended client behaves exactly as a suspended one (tokens revoked, authorize and token endpoints refuse) but none of the organization's own controls lift it. A platform-suspended organization loses every member's and every bound client's tokens, and every change to the organization is refused until the suspension is lifted; it also cannot be deleted while suspended.
OrganizationDeleted follows the same shape without being a platform act: whoever deleted the
organization — an owner or a global admin — stands in "UserId" and "ActorId", and
"TenantId" holds the deleted organization. Like a deleted client, a deleted organization's audit
rows are all that remains of it.
To see everything the platform has done to an organization:
SELECT "EventType", "ActorId", "ClientId", "Reason", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "TenantId" = '$tenant_id'
AND "EventType" LIKE '%ByPlatform'
ORDER BY "OccurredAt" DESC;
Querying Events
All examples use the auth_audit schema. Substitute real UUIDs for $user_id and $tenant_id.
Every column identifier is double-quoted, because the columns are PascalCase — an unquoted
user_id or occurredat does not exist and the query errors out.
Recent logins for a user:
SELECT "Id", "EventType", "IpAddress", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "UserId" = '$user_id'
AND "EventType" = 'LoginSucceeded'
ORDER BY "OccurredAt" DESC
LIMIT 50;
Failed login attempts in the last 24 hours (across a tenant):
SELECT "UserId", "IpAddress", COUNT(*) AS attempts
FROM auth_audit.auth_audit_entries
WHERE "TenantId" = '$tenant_id'
AND "EventType" = 'LoginFailed'
AND "OccurredAt" >= now() - INTERVAL '24 hours'
GROUP BY "UserId", "IpAddress"
ORDER BY attempts DESC;
All security events for a user (ordered most recent first):
SELECT "EventType", "IpAddress", "UserAgent", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "UserId" = '$user_id'
ORDER BY "OccurredAt" DESC;
Lockout events in a date range:
SELECT "UserId", "EventType", "IpAddress", "OccurredAt"
FROM auth_audit.auth_audit_entries
WHERE "TenantId" = '$tenant_id'
AND "EventType" IN ('AccountLockedOut', 'MfaLockedOut')
AND "OccurredAt" BETWEEN '2026-01-01' AND '2026-02-01'
ORDER BY "OccurredAt" DESC;
Retention Policy
No automatic retention policy is applied out of the box. The auth_audit_entries table grows indefinitely. For production deployments, add a scheduled job (for example, a PostgreSQL pg_cron rule or an application-level Hangfire job) to delete rows older than your required retention window:
-- Example: delete entries older than 90 days
DELETE FROM auth_audit.auth_audit_entries
WHERE "OccurredAt" < now() - INTERVAL '90 days';
Consider adding an index on "OccurredAt" before running this at scale:
CREATE INDEX IF NOT EXISTS ix_auth_audit_entries_occurred_at
ON auth_audit.auth_audit_entries ("OccurredAt");
Extending Audit Coverage
IAuthAuditService is a shared-kernel interface available to any module. The implementation (AuthAuditService in Wallow.Shared.Infrastructure.Core) writes to the same auth_audit_entries table. Calling it from another module only requires injecting the interface.
1. Add a Wolverine handler in your module's Infrastructure project:
using Wallow.Shared.Contracts.Identity.Events;
using Wallow.Shared.Kernel.Auditing;
using Wolverine.Attributes;
[WolverineHandler]
public static class MyModuleAuditHandlers
{
public static Task Handle(UserSessionEvictedEvent message, IAuthAuditService authAuditService)
{
return authAuditService.RecordAsync(new AuthAuditRecord
{
EventType = "SessionEvicted",
UserId = message.UserId,
TenantId = message.TenantId,
OccurredAt = DateTimeOffset.UtcNow
}, CancellationToken.None);
}
}
Your module's IWallowModule.HandlerAssemblies already covers its own Infrastructure project, so
there is no registration call to write — but
[WolverineHandler] is load-bearing here and must not be dropped. Conventional discovery finds a
public concrete type only if it implements IWolverineHandler, carries [WolverineHandler], or has
a type name ending in Handler or Consumer. The name is the whole story: AuthAuditEventHandlers
is plural, so it matches none of the three and needs the attribute, without which every method in it
is silently unreachable and nothing is ever audited. Static-ness is not the problem — 29 public static class …Handler types elsewhere in api/src are discovered fine with no attribute, which is
why api/CLAUDE.md can say static handlers need no registration.
If you name a handler class …Handlers (plural) and forget the attribute, nothing fails at startup
and nothing logs — the messages simply go nowhere.
2. Use a descriptive, consistent EventType string. Use PascalCase. Prefix with a module name if the event is module-specific (e.g., Storage.FileUploaded).
3. Populate IpAddress only when it is available on the source event. Do not fabricate or forward stale IP values.
IAuthAuditService swallows exceptions internally and logs them at Error level, so a database outage does not propagate back to the caller.
Related Documentation
- Observability Guide — structured logging, tracing, and metrics
- Troubleshooting Guide — diagnosing issues in production
- Messaging Guide — how Wolverine events work