Audit Logging
Auth events go to an auth.AuditLogger. Nothing is recorded until you configure one, and everything that emits events treats a nil logger as "off".
type AuditLogger interface {
LogAuthEvent(ctx context.Context, event AuditEvent)
LogLoginAttempt(ctx context.Context, userID, result string, details map[string]any)
LogAccessAttempt(ctx context.Context, userID, resource, action, result string, details map[string]any)
LogRoleCheck(ctx context.Context, userID, role, result string, details map[string]any)
LogPermissionCheck(ctx context.Context, userID, permission, result string, details map[string]any)
}
Log to slog
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
provider := auth.NewJWTAuthProvider(jwt).
WithAuditLogger(auth.NewSlogAuditLogger(logger))
app := ezz.New().Logger(logger).Auth(provider)
Passing nil to NewSlogAuditLogger uses slog.Default(). Events are written at info level under the message auth_event, with the user ID, action, resource, result, request ID, IP address, user agent, and any extra details as attributes.
auth.NewNoOpAuditLogger() discards everything, which is useful in tests or to disable auditing without changing the wiring.
What gets recorded today
| Event | Emitted by | Method |
|---|---|---|
| Token validation failure | JWTAuthProvider.Authenticate | LogAccessAttempt with reason: invalid_token |
| GoTrue sign-in and sign-up, success or failure | The GoTrue proxy on /token and /signup | LogLoginAttempt |
| Every other proxied GoTrue call | The GoTrue proxy | LogAccessAttempt |
The proxy details include the GoTrue path, the resolved client IP, the request ID, and the user agent. See GoTrue proxy.
Successful token validation is not logged, and role and permission decisions are not logged automatically. Call the logger yourself where you need those:
app.DELETE("/users/{id}", ezz.H(func(ctx *ezz.Context, req DeleteUserRequest) (any, error) {
user := ctx.User().(auth.User)
audit.LogAccessAttempt(ctx, user.ID(), "/users/"+req.ID, "DELETE", "success", map[string]any{
"request_id": ctx.GetRequestID(),
})
return nil, store.Delete(ctx, req.ID)
})).Auth(ezz.Role("admin"))
Write your own logger
Implement the interface to send events to a database, a queue, or a SIEM. The event struct is what a full record looks like:
type AuditEvent struct {
Timestamp time.Time
UserID string
Action string
Resource string
Result string
Details map[string]any
RequestID string
IPAddress string
UserAgent string
}
type DBAuditLogger struct {
events chan auth.AuditEvent
}
func NewDBAuditLogger(db *sql.DB) *DBAuditLogger {
l := &DBAuditLogger{events: make(chan auth.AuditEvent, 1024)}
go l.drain(db)
return l
}
func (l *DBAuditLogger) LogAuthEvent(ctx context.Context, event auth.AuditEvent) {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
select {
case l.events <- event:
default:
// prefer dropping an event over blocking the request
}
}
func (l *DBAuditLogger) LogLoginAttempt(ctx context.Context, userID, result string, details map[string]any) {
l.LogAuthEvent(ctx, auth.AuditEvent{UserID: userID, Action: "login", Result: result, Details: details})
}
// LogAccessAttempt, LogRoleCheck, and LogPermissionCheck follow the same pattern
Two things to keep in mind when writing one:
- Auditing happens on the request path. Buffer or batch anything that talks to the network, and never block the request on a slow sink.
- The context you receive is the request context, so it is cancelled when the response finishes. Copy what you need before handing work to a goroutine.
Use the same logger for the proxy
GoTrueConfig takes the same interface, so proxy traffic and your own routes land in one stream:
audit := auth.NewSlogAuditLogger(logger)
gt := auth.NewGoTrue(auth.GoTrueConfig{
InternalURL: "http://gotrue:9999",
JWTSecret: secret,
AuditLogger: audit,
})
app := ezz.New().Auth(gt.AuthProvider().WithAuditLogger(audit))
gt.Mount(app, "/auth")
X-Request-ID response header.Next steps
- GoTrue proxy for what the proxy records.
- Roles and permissions for the decisions worth auditing.