Help us shape the future of Go! Take the 2026 Go Developer Survey

audit

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 12 Imported by: 0

README

github.com/ubgo/auth/plugin/audit

Records security events: every mutation operation that ran, successful or refused, with who did it, to whom, from where, and how it ended. Sign-in successes and failures, sign-outs, password and address changes, factor changes, bans, impersonations and key creation are all operations, so one hook on the pipeline sees them all, and an operation a plugin adds tomorrow is logged the day it ships.

Install

go get github.com/ubgo/auth

The package is in the core module github.com/ubgo/auth, so it brings no extra dependency beyond github.com/ubgo/sqlb.

Wire it

au := audit.New()
a, err := auth.New(
	auth.WithDB(db, sqlb.PostgresDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(email.New(email.Login(), email.Recover(), email.WithMailer(m)), password.New(), admin.New(), au),
)
// then a.Migrate(ctx), which creates audit_event

The plugin depends on no other plugin. With the admin plugin registered, admins read the log at GET /auth/admin/audit; from Go, au.Events(ctx, audit.Query{...}) reads it with no authorization of its own.

What is recorded

An event's kind is the operation's permanent id — password.sign-in, core.sign-out, admin.ban — one vocabulary, the one every transport and rate rule already uses, rather than a second list that drifts from it. By default every Mutation operation is recorded and no Query is (a session check per page view would fill the table with noise), except an OAuth callback or id-token sign-in that resolved a user: it is a GET, yet it signs in, creates or links, so it is recorded whenever the OAuth plugin annotated it (oauth.AnnotationAction); audit.Queries() records reads too and audit.Skip(ids...) drops operations you do not care about.

Each event carries: op, plugin, transport (http, go), ok, code (the *auth.Error code of a refusal, or error for a failure the client was not told about, such as a database error — never the error's text), actorId (the signed-in caller, or the user a sign-in signed in), sessionId, subjectId (the user the call was about when that is someone else: the target of an admin operation, or the user an impersonation acts as), impersonatorId (set for a call made while impersonating), identifier (what an anonymous call named, such as the address typed at a failed sign-in, so attempts can be counted per account whether or not it exists), ip, userAgent, at, severity (info, warn or alert), orgId (the organization the call acted in, from auth.AnnotationOrganization or an input implementing OrgScoped) and type (better-auth's event name, derived from op).

A call over an auth.RateRule's limit is recorded too, whatever its action did with it: one event of type rate_limit_exceeded per rule window (a flood writes one row, not one per request), ok when the call went through, with the code rate. plus the outcome (rate.blocked, rate.logged, rate.challenged, rate.challenge_passed) and the operation the rule limits. audit.Skip drops these for an operation like any other.

Never recorded: the operation's input or output, so no password, code, token or key can reach the log. A refusal before the input was decoded (rate limit, origin check, session requirement, malformed body) is not recorded either: it says nothing about an account, and an anonymous flood of them must not be able to fill the table. A refused sign-in writes one row through one code path whether or not the account exists, so neither the row's presence nor its shape reveals the account.

Severity. Every event stores severity, the core's auth.SecuritySeverity scale (audit.SeverityInfo, SeverityWarn, SeverityAlert). DefaultSeverity ranks a rate-limit event warn when it blocked or challenged and info when it only logged or the challenge was passed, any other failure warn, an administrator's successful ban, delete, impersonation or password set on another user alert, their other actions on another user warn, and everything else info. A failure is never above warn: a refused action changed nothing. audit.Severities and audit.SeverityFunc override it, and Query.Severity / Query.MinSeverity (also severity and minSeverity on the admin route) filter by it. A caller of Record may set Severity itself.

Redaction. audit.Redact(r) installs a Redactor that sees every event once, after its severity was decided and just before the insert or the sink, on all three write paths (operation hooks, rate-limit events, Record), which share one function. It may change the personal fields only; the rest is restored. audit.MaskPIIRedactor (audit.MaskPII as a plain function) truncates the IP to /24 or /48, drops an IP that does not parse, and masks an email identifier's local part. Off unless passed.

Personal data. The table holds IP addresses, user agents and the identifiers (addresses, numbers, handles) typed at anonymous calls, normalized. It is personal data under most privacy laws: say so in your privacy notice, run a.Sweep on a schedule so nothing outlives the retention, and shorten it with audit.Retention where the law or your policy asks for less. auth.WithoutIPTracking leaves the IP empty.

A success is recorded inside the operation's transaction: there is no sign-in without its row, and no row for a sign-in that rolled back. A failure is recorded after the transaction rolled back, because the row must survive the rollback; if that recording fails, the operation's own error still reaches the client and the recording failure is logged at ERROR.

An operation input that implements Targeted (TargetUserID() string) fills subjectId; the admin plugin's requests do, and yours can without importing this package.

Options

Option What it does Default
audit.To(sink) Send events to a Sink instead of the table (a SIEM, a queue, a file). The plugin then declares no table and Sweep deletes nothing. Reads (Events, my-events, the admin audit route) go to the sink when it also implements Querier, and answer ErrNoQueryBackend when it does not the table
audit.Types(map) Map operations to event types: your own sign-in operation to audit.TypeUserSignedIn, or a built-in one to a name of your choosing. Wins over every EventTyper plugin and the built-in mapping the built-in mapping
audit.Retention(d) How long an event stays before a.Sweep deletes it 90 days
audit.Queries() Record Query operations too mutations only
audit.Skip(ids...) Never record the listed operations none
audit.Severities(map) Override the severity of whole operations. A value that is not info, warn or alert fails auth.New DefaultSeverity
audit.SeverityFunc(fn) Decide from the whole event; fn(event, current) gets the severity the default and Severities reached, and an unknown result is ignored. Runs inside the operation's transaction: fast, no database writes. Nil is refused none
audit.Redact(r) Run a Redactor on every event before it is stored or sent to a sink, on every write path. Only personal fields can change; operation, outcome, code, severity and type are restored. Nil is refused off

A sink's error on a success refuses the operation: an app that cannot record a sign-in does not grant it. On a failure the sink is called after the rollback and its error is only logged.

A sink that should serve the read side implements Querier beside Record. It receives the query with Page and Limit already resolved, matches UserID against actor or subject, and matches Type against the Type the event was recorded with (set before Record is called, so storing it is enough); an event it returns without a Type gets one from the plugin's mapping:

type siem struct{ client *siemClient }

func (s siem) Record(ctx context.Context, e audit.Event) error { return s.client.Send(ctx, e) }

func (s siem) Events(ctx context.Context, q audit.Query) (audit.Page, error) {
	return s.client.Search(ctx, q) // the SIEM's own query API
}

au := audit.New(audit.To(siem{client}))

Event types of other plugins. A sign-in operation needs no mapping: every operation whose auth.OpInfo sets SignIn is user_signed_in, the built-in ones and a third-party sign-in method's alike, and so is every OAuth provider's callback and id-token route. Any other type a plugin that is not built in wants for its operations (a sign-up, a ban) it declares by implementing EventTyper, with strings so it needs no import of this package; the audit plugin collects them at Init. Precedence: the SignIn flag, then the built-in mapping, then every EventTyper in wiring order, then audit.Types, so password.change-expired, which signs in, stays password_changed.

func (p *myIdP) AuditEventTypes() map[auth.EndpointID]string {
	return map[auth.EndpointID]string{"myidp.callback": "user_signed_in"}
}

Go API

Method What it does
Record(ctx, audit.Event{Op, Plugin, OK, SubjectID, …}) Writes an event for something no operation hook saw: a Go-only call from trusted code, such as admin.BanFor during an import. At defaults to now and Transport to go; it joins the transaction in ctx
Events(ctx, audit.Query{UserID, Op, OrgID, Type, Page, Limit}) One page of events, newest first, with total. UserID matches the actor or the subject, so one query is "everything about this user"; Op narrows to one operation, OrgID to one organization, Type to one event type, Severity to one severity, MinSeverity to that and above. Limit defaults to auth.WithDefaultPageSize, else 50, and is clamped to 200
Sweep(ctx, cutoff) Called by a.Sweep: deletes events older than the retention (the core's cutoff is ignored; the log keeps its own, longer memory)
MyEvents(ctx, audit.MyQuery{Type, OrgID, Page, Limit}) The signed-in user's own history: events they made or that were made to them. On an event someone else made, that person's id, session, address and user agent are left out, and no event shows an impersonator
TypeOf(op), OpsOf(type) The event type of an operation, and the operations of a type, under the built-in mapping
au.TypeOf(op), au.OpsOf(type) The same under this plugin's mapping: built-in, EventTyper plugins and Types

Query also takes OrgID (one organization's events) and Type, better-auth's event name (user_signed_in, user_banned, password_reset_requested, organization_member_removed, …; the Type* constants). A type is derived from the operation id when an event is read, never stored, so it matches every operation that produces it, the OAuth callbacks of every provider included; an operation without a better-auth name has its own id as its type. Every event carries type and, when the call acted in one, orgId: the organization the operation annotated (c.Annotate(auth.AnnotationOrganization, id)), or else the one its input names through OrgScoped (AuditOrgID() string). The organization plugin annotates every operation, so its events carry the organization whether the request named it, used the active one, or reached it through a team or an invitation.

Route

GET /auth/audit/my-events?type=&orgId=&page=&limit= serves MyEvents to the signed-in user (better-auth's client audit-log query). Admins read everything at GET /auth/admin/audit, which takes the same orgId and type filters.

The plugin serves no route of its own. The admin plugin's GET /auth/admin/audit?userId=&op=&page=&limit= answers a Page for admins (Audit(ctx, audit.Query) from Go).

Table

audit_event, alias ae, owned by this plugin. No foreign key to auth_user: an event about a deleted user is exactly what an investigation needs to keep, and a ban's row must outlive the account it ended. Indexed on (actor_id, at) and (subject_id, at).

Column Type Meaning
id key aud_…
at timestamp when the operation ended
op, plugin, transport key the operation, its plugin, how it arrived
ok bool success
code key error code of a refusal, error for an untold failure, empty on success
actor_id, session_id key the caller, or the user signed in
subject_id key the user acted on, when not the actor
impersonator_id key the admin behind an impersonated call
identifier value what an anonymous call named (320 characters at most)
ip key client address; empty under auth.WithoutIPTracking, which this plugin honours like sessions do
user_agent long cut to 1024 characters
org_id key the organization the call acted in, or empty (migration 0002_audit_event_org, indexed on (org_id, at))
severity key info, warn or alert (migration 0003_audit_event_severity, default info so older rows need no backfill, indexed on (severity, at))

Typed columns: audit.EventAt, audit.EventOp, audit.EventActorID and so on, for your own queries over audit.TableEvent.

Errors

Error Code HTTP When
ErrNoQueryBackend audit.no_query_backend 501 A read (Events, my-events, the admin audit route) while events go to a sink that does not implement Querier: the events exist where the plugin cannot read them, so an empty page would be a lie

It refuses nothing else: a page or limit that is not a number is refused by the transport's decoding, and a sink's error on a success is returned to the operation's caller as it is. A Types entry or an EventTyper declaration with an empty type fails auth.New.

Security notes

Property Pinned by
A successful sign-in, a failed one with its code and identifier, an admin action with actor and subject, an impersonated call with its impersonator, and a sign-out with its session are recorded; queries are not by default TestAudit_RecordsSignInsFailuresAndAdminActions
Sweep deletes only events older than the retention; Queries and Skip apply TestAudit_SweepRetentionQueriesOptionAndSkip
A sink that cannot record a success refuses the operation; a sink that fails on a failure only logs TestAudit_SinkReplacesTheTableAndItsRefusalRefusesTheOperation
A refused sign-in writes the same row, through the same path, for a known and an unknown account; a pre-decode refusal writes nothing TestAudit_FailedSignInRowIsTheSameForKnownAndUnknownAccounts
A failed audit write for a refused sign-in never changes the sign-in's answer TestAudit_RecordingFailureNeverChangesTheAnswer
Every database failure while recording, reading or sweeping reaches the caller TestAudit_EveryDatabaseFailureSurfaces
The error's text never reaches the log, only its code TestEventOf_ActorSubjectImpersonatorAndCode
A sink with no read side refuses every read with ErrNoQueryBackend; a Querier sink serves them with page and limit resolved, and its error surfaces TestEvents_SinkWithoutQuerierRefuses, TestEvents_SinkQuerierServesReads, TestAuditSeams_WriteOnlySinkRefusesEveryRead
A third-party sign-in plugin's operation is user_signed_in through EventTyper; Types wins; the default mapping is unchanged TestTypes_ThirdPartySignInIsUserSignedIn, TestAuditSeams_ThirdPartySignInIsUserSignedIn
An operation marked auth.OpInfo.SignIn is user_signed_in with no mapping; an unmarked one keeps its id; Types wins over the flag TestTypes_SignInFlagIsUserSignedIn
Every operation id the built-in mapping names is an operation a built-in plugin or the core registers TestTypes_NameRealOperations (integration)
Exactly the built-in sign-ins are marked, every operation the log typed user_signed_in before the flag is among them, and under a wired audit plugin every operation keeps its type except device.token and emailchallenge.verify-otp, which become user_signed_in; every OAuth callback and id-token route gets the same type from the flag as from its id's shape TestSignInFlag_MarksExactlyTheSignIns, TestSignInFlag_AuditTypesAgree
Severity is stored in the column, ranked by the default, overridden by the map and the func, and filtered by Severity and MinSeverity; an unknown map value fails auth.New TestDefaultSeverity, TestSeverityOf_MapThenFunc, TestSeverities_UnknownValueFailsInit, TestAuditBatch4_SeverityStoredQueriedAndOverridden, TestAuditBatch4_SeverityOverride
With Redact on, the stored row (read back with SQL) holds the masked network and email, on a success, a failure and a Record; a redactor cannot change an event's operation, outcome, code, severity or type TestAuditBatch4_RedactionRemovesDataFromTheStoredRow, TestAuditBatch4_NoRedactorStoresWhole, TestRedact_KeepsTheFactsOfTheEvent, TestMaskPII
A user's own history never shows another party's id, session, address or user agent, nor an impersonator; queries by organization and by type return only those events TestR30_AuditLog

better-auth's account_linked is read from the OAuth plugin's call annotation (oauth.AnnotationAction is AccountLinked or AccountLinkedByEmail): every OAuth link (explicit flow or implicit by verified email) adds an event oauth.<provider>-linked (LinkedOp) beside the callback's user_signed_in, inside the flow's transaction.

Limits: no export format; inputs and outputs are never recorded, only who, what, whom and how it ended.

Documentation

Overview

Package audit records security events: every mutation operation that ran, successful or refused, with who did it, to whom, from where, and how it ended. Sign-in successes and failures, sign-outs, password and address changes, factor changes, bans, impersonations and key creation are all operations, so one hook on the pipeline sees them all, and an operation a plugin adds tomorrow is logged on the day it ships.

An event's kind is the operation's permanent id ("password.sign-in", "admin.ban"): one vocabulary, the one every transport and rate rule already uses, rather than a second list that drifts from it.

Events go to the plugin's own table (audit_event) or, with To, to a Sink the app provides, such as a SIEM or a message queue. A success is recorded inside the operation's transaction, so there is no sign-in without its row and no row for a sign-in that rolled back; a failure is recorded after the transaction rolled back, because the row must survive the rollback.

Index

Constants

View Source
const (
	TableNameEvent  = "audit_event"
	TableAliasEvent = "ae"

	ColID             = "id"
	ColAt             = "at"
	ColOp             = "op"
	ColPlugin         = "plugin"
	ColTransport      = "transport"
	ColOK             = "ok"
	ColCode           = "code"
	ColActorID        = "actor_id"
	ColSessionID      = "session_id"
	ColSubjectID      = "subject_id"
	ColImpersonatorID = "impersonator_id"
	ColIdentifier     = "identifier"
	ColIP             = "ip"
	ColUserAgent      = "user_agent"
	ColOrgID          = "org_id"

	// IDPrefixEvent starts every event id.
	IDPrefixEvent = "aud"
)

Table and column names. Prefixed with the plugin name so no other table can collide with them.

View Source
const (
	// DefaultRetention is how long an event stays before Sweep deletes it:
	// long enough for a quarterly review and for most breach investigations,
	// which start weeks after the fact.
	//
	//ds:def id=defaultretention-ezyh85jc owner=@khanakia stability=stable
	DefaultRetention = 90 * 24 * time.Hour
	// DefaultPageSize is one screen of events.
	//
	//ds:def id=defaultpagesize-977e4tgv owner=@khanakia stability=stable
	DefaultPageSize = 50
	// MaxPageSize caps a page so one request cannot read the whole log; a
	// larger limit is clamped, not refused.
	//
	//ds:def id=maxpagesize-szt46abb owner=@khanakia stability=stable
	MaxPageSize = 200
)

Limits.

View Source
const (
	// SeverityInfo is expected traffic: a sign-in that worked, a profile edit.
	SeverityInfo = auth.SeverityInfo
	// SeverityWarn is something refused or challenged: a failed call, a rate
	// block, an administrator acting on another user's account.
	SeverityWarn = auth.SeverityWarn
	// SeverityAlert is an action that ended or took over somebody else's
	// account: an administrator banning, deleting or impersonating a user, or
	// setting their password.
	SeverityAlert = auth.SeverityAlert
)

The three severities, the core's constants under this package's names.

View Source
const (
	ColSeverity = "severity"
)

ColSeverity is the severity column, added by migration 0003. It is NOT NULL with the default "info", so rows written before the migration read as info and the migration needs no backfill pass.

View Source
const EndpointMyEvents auth.EndpointID = "audit.my-events"

EndpointMyEvents is the self-service history operation's id.

View Source
const PluginName = "audit"

PluginName is the plugin's permanent identity and its migration owner.

View Source
const RateCodePrefix = "rate."

RateCodePrefix starts the Code of every rate-limit event, followed by the auth.RateOutcome: "rate.blocked", "rate.logged", "rate.challenged", "rate.challenge_passed". It is what makes such an event TypeRateLimitExceeded.

Variables

View Source
var (
	EventID             = sqlb.Col[string](TableEvent, ColID)
	EventAt             = sqlb.Col[time.Time](TableEvent, ColAt)
	EventOp             = sqlb.Col[string](TableEvent, ColOp)
	EventPlugin         = sqlb.Col[string](TableEvent, ColPlugin)
	EventTransport      = sqlb.Col[string](TableEvent, ColTransport)
	EventOK             = sqlb.Col[bool](TableEvent, ColOK)
	EventCode           = sqlb.Col[string](TableEvent, ColCode)
	EventActorID        = sqlb.Col[string](TableEvent, ColActorID)
	EventSessionID      = sqlb.Col[string](TableEvent, ColSessionID)
	EventSubjectID      = sqlb.Col[string](TableEvent, ColSubjectID)
	EventImpersonatorID = sqlb.Col[string](TableEvent, ColImpersonatorID)
	EventIdentifier     = sqlb.Col[string](TableEvent, ColIdentifier)
	EventIP             = sqlb.Col[string](TableEvent, ColIP)
	EventUserAgent      = sqlb.Col[string](TableEvent, ColUserAgent)
	EventOrgID          = sqlb.Col[string](TableEvent, ColOrgID)
)

Typed columns of audit_event.

View Source
var ErrNoQueryBackend = auth.NewError("audit.no_query_backend", http.StatusNotImplemented, "The audit log cannot be read here.")

ErrNoQueryBackend refuses a read of the log (Events, my-events, the admin plugin's audit route) when events go to a Sink that cannot answer queries: the events exist somewhere this plugin cannot read, and an empty page would claim there are none. Implement Querier on the sink to serve the read side.

View Source
var EventSeverity = sqlb.Col[string](TableEvent, ColSeverity)

EventSeverity is the typed severity column.

View Source
var TableEvent = sqlb.Table{Name: TableNameEvent, Alias: TableAliasEvent}

TableEvent is the events table, for an app's own queries.

Functions

func LinkedOp

func LinkedOp(provider string) auth.EndpointID

LinkedOp is the Op of the account_linked event recorded for provider.

func OpsOf

func OpsOf(t EventType) []string

OpsOf lists the operation ids whose events have type t, sorted: the operations mapped to it, plus t itself read as an operation id, which is what TypeOf answers for an unmapped one. The per-provider OAuth callbacks and link events are not listed, since their ids depend on the providers wired; typeFilter matches them by pattern.

Types

type Event

type Event struct {
	ID             string          `json:"id"`
	At             time.Time       `json:"at"`
	Op             auth.EndpointID `json:"op"`
	Plugin         string          `json:"plugin"`
	Transport      auth.Transport  `json:"transport"`
	OK             bool            `json:"ok"`
	Code           string          `json:"code,omitempty"`
	ActorID        string          `json:"actorId,omitempty"`
	SessionID      string          `json:"sessionId,omitempty"`
	SubjectID      string          `json:"subjectId,omitempty"`
	ImpersonatorID string          `json:"impersonatorId,omitempty"`
	Identifier     string          `json:"identifier,omitempty"`
	IP             string          `json:"ip,omitempty"`
	UserAgent      string          `json:"userAgent,omitempty"`
	// OrgID is the organization the call acted in: the one the operation
	// annotated (auth.AnnotationOrganization), else the one its input named
	// (OrgScoped); "" otherwise.
	OrgID string `json:"orgId,omitempty"`
	// Severity ranks the event (DefaultSeverity, Severities, SeverityFunc),
	// decided when it is recorded and stored, so the log can be read from the
	// serious rows down. A caller of Plugin.Record may set it; "" is decided.
	Severity Severity `json:"severity"`
	// Type is better-auth's name for what happened (TypeOf), derived from
	// Op when the event is read or recorded, never stored: the vocabulary
	// can grow without a migration.
	Type EventType `json:"type"`
}

Event is one recorded operation.

ActorID is the signed-in user who made the call, or the user a successful sign-in signed in (the issued session's user), or "" for an anonymous failure. SubjectID is the user the call was about when that is someone else: the target of an admin operation (an input with a TargetUserID method) or the user an impersonation acts as. ImpersonatorID is set when the call was made during an impersonation. Identifier is what an anonymous call named (the address or number typed at sign-in), when its input reports one through auth.Identified, so failed sign-ins can be counted per account without the account existing. Code is the *auth.Error code of a failure, or "error" for a failure the client was not told about.

func MaskPII

func MaskPII(e Event) Event

MaskPII is the default redaction, a RedactorFunc-compatible function: the IP is truncated to its /24 (IPv4) or /48 (IPv6) network address, written as the prefix ("203.0.113.0/24"); an IP that does not parse is dropped, since an unparseable value can hold anything; an Identifier that is an email keeps the first character of its local part and the domain ("b***@example.com"). Other identifiers (phone numbers, usernames) and the user agent are left alone: mask them with a Redactor of your own if your policy counts them as personal data. Use it as audit.Redact(audit.RedactorFunc(audit.MaskPII)) with ctx ignored, or through MaskPIIRedactor.

type EventType

type EventType string

EventType is better-auth's name for what an event records (user_signed_in, organization_created, …). The log stores the operation id, the one vocabulary every transport already uses; the type is derived from it by TypeOf, so a query can ask for "every sign-in" across the dozen operations that sign someone in.

const (
	TypeUserSignedUp            EventType = "user_signed_up"
	TypeUserSignedIn            EventType = "user_signed_in"
	TypeUserSignedOut           EventType = "user_signed_out"
	TypeUserProfileUpdated      EventType = "user_profile_updated"
	TypeUserEmailVerified       EventType = "user_email_verified"
	TypeUserBanned              EventType = "user_banned"
	TypeUserUnbanned            EventType = "user_unbanned"
	TypeUserDeleted             EventType = "user_deleted"
	TypeSessionRevoked          EventType = "session_revoked"
	TypeSessionsRevokedAll      EventType = "sessions_revoked_all"
	TypeUserImpersonated        EventType = "user_impersonated"
	TypeImpersonationStopped    EventType = "user_impersonation_stopped"
	TypeAccountLinked           EventType = "account_linked"
	TypeAccountUnlinked         EventType = "account_unlinked"
	TypePasswordChanged         EventType = "password_changed"
	TypePasswordResetRequested  EventType = "password_reset_requested"
	TypePasswordResetCompleted  EventType = "password_reset_completed"
	TypeEmailVerificationSent   EventType = "email_verification_sent"
	TypeOrganizationCreated     EventType = "organization_created"
	TypeOrganizationUpdated     EventType = "organization_updated"
	TypeOrganizationDeleted     EventType = "organization_deleted"
	TypeMemberRemoved           EventType = "organization_member_removed"
	TypeMemberRoleUpdated       EventType = "organization_member_role_updated"
	TypeInvitationCreated       EventType = "organization_invitation_created"
	TypeInvitationAccepted      EventType = "organization_invitation_accepted"
	TypeInvitationRejected      EventType = "organization_invitation_rejected"
	TypeInvitationCanceled      EventType = "organization_invitation_cancelled"
	TypeTeamCreated             EventType = "organization_team_created"
	TypeTeamUpdated             EventType = "organization_team_updated"
	TypeTeamDeleted             EventType = "organization_team_deleted"
	TypeTeamMemberAdded         EventType = "organization_team_member_added"
	TypeTeamMemberRemoved       EventType = "organization_team_member_removed"
	TypeOrganizationRoleCreated EventType = "organization_role_created"
	TypeOrganizationRoleUpdated EventType = "organization_role_updated"
	TypeOrganizationRoleDeleted EventType = "organization_role_deleted"
	// TypeRateLimitExceeded is a call over an auth rate rule's limit
	// (auth.RateEvent), whatever the rule's action did with it; the event's
	// Code says which (RateCodeBlocked and its siblings). Unlike every other
	// type it is derived from Code, not Op: Op is the operation that was
	// limited, so a query by Op finds its limit events too.
	TypeRateLimitExceeded EventType = "rate_limit_exceeded"
)

Event types, as better-auth's audit log names them.

func TypeOf

func TypeOf(op auth.EndpointID) EventType

TypeOf is the event type of an operation under the built-in mapping: better-auth's name when there is one, otherwise the operation id itself, so every event has a type and an app's own operations are queryable by type too. Plugin.TypeOf adds EventTyper plugins and Types.

type EventTyper

type EventTyper interface {
	AuditEventTypes() map[auth.EndpointID]string
}

EventTyper is a plugin that names the event types of its own operations, for a type the operation's metadata cannot say: a sign-in operation needs none, since auth.OpInfo.SignIn already makes it user_signed_in, but a third-party sign-up or ban does. Found among the registered plugins at Init; the values are strings so the plugin needs no import of this package (TypeUserSignedIn is "user_signed_in"). Types overrides it.

type MyQuery

type MyQuery struct {
	Type  EventType `json:"type,omitempty"`
	OrgID string    `json:"orgId,omitempty"`
	Page  int       `json:"page,omitempty,string"`
	Limit int       `json:"limit,omitempty,string"`
}

MyQuery is the input of my-events: the caller's own history, narrowed by type and organization, paged as Query.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func Queries

func Queries() Option

Queries records Query operations too (session reads, lists), not only mutations. Expect many rows.

func Redact

func Redact(r Redactor) Option

Redact installs r to run on every event before it is written. Off by default: without it events are stored whole. Nil is refused. Redaction is lossy on purpose; a masked Identifier no longer matches Query.Identifier for the address that was typed.

func Retention

func Retention(d time.Duration) Option

Retention sets how long an event stays before Sweep deletes it; zero or negative keeps DefaultRetention.

func Severities

func Severities(m map[auth.EndpointID]Severity) Option

Severities overrides the severity of whole operations ("password.sign-in" as warn, an app's "exports.request" as alert), over DefaultSeverity. A repeated call adds to the earlier ones and a later entry wins. It applies to every outcome of the operation, success and failure alike; use SeverityFunc for a rule that depends on the outcome. A value that is not one of auth.SecuritySeverityValues fails Init, since an unrankable row would never match a severity filter.

func SeverityFunc

func SeverityFunc(f func(e Event, current Severity) Severity) Option

SeverityFunc decides every event's severity itself; it receives the event and the severity the default and Severities arrived at, and returns the one to store. A result that is not one of auth.SecuritySeverityValues is replaced by that incoming severity, so a bug in the func cannot write an unfilterable row. It must be fast and must not write to the database: it runs inside the operation's transaction. Nil is refused.

func Skip

func Skip(ids ...auth.EndpointID) Option

Skip never records the listed operations, for a noisy one an app does not care about, such as a profile edit.

func To

func To(s Sink) Option

To sends events to sink instead of the table. The plugin then declares no table and Sweep deletes nothing; retention is the sink's business. Reading the log needs the sink to implement Querier too; otherwise every read answers ErrNoQueryBackend.

func Types

func Types(m map[auth.EndpointID]EventType) Option

Types maps operations to event types, over the built-in mapping and every EventTyper plugin: an app's own sign-in operation to TypeUserSignedIn, or a built-in operation to a name of the app's choosing. Repeated calls add up; a later one wins for the same operation. An empty type fails Init.

type OrgScoped

type OrgScoped interface {
	AuditOrgID() string
}

OrgScoped is an operation input that names the organization the call acts in; the event's OrgID is filled from it. The organization plugin's requests implement it, and an app's own operations can, without importing this package.

type Page

type Page struct {
	Events []Event `json:"events"`
	Total  int64   `json:"total"`
	Page   int     `json:"page"`
	Limit  int     `json:"limit"`
}

Page is one page of events, newest first, with the total for a pager and the page and limit actually used.

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin is the audit plugin. Build it with New.

func New

func New(opts ...Option) *Plugin

New builds the plugin.

func (*Plugin) Events

func (p *Plugin) Events(ctx context.Context, q Query) (Page, error)

Events reads the table from Go, with no authorization of its own: the caller decides who may see the log. The admin plugin serves it to admins as GET /admin/audit. With a Sink there is no table: the read goes to the sink when it implements Querier, and is ErrNoQueryBackend otherwise.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core and builds the event-type mapping: every operation marked auth.OpInfo.SignIn as TypeUserSignedIn, then the built-in one, then every registered EventTyper, then Types, each overriding the one before. An empty type is refused, since it would make an event untypeable.

func (*Plugin) MyEvents

func (p *Plugin) MyEvents(ctx context.Context, q MyQuery) (Page, error)

MyEvents is the signed-in user's own history (better-auth's client audit-log query): events they made or that were made to them. On an event someone else made (an admin's ban), the other party's id, session, address and user agent are left out, and no event shows an impersonator, so a user's history never reveals who an administrator is or where they work from.

func (*Plugin) Name

func (p *Plugin) Name() string

Name identifies the plugin.

func (*Plugin) OperationHooks

func (p *Plugin) OperationHooks() []auth.OperationHook

OperationHooks record every watched operation: a success in After, inside its transaction; a failure in Finally, after the rollback, where a recording failure can only be logged because the operation has already answered. The hook has an empty Match on purpose: a list of operations would be the second vocabulary this package exists to avoid.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations serves my-events.

func (*Plugin) OpsOf

func (p *Plugin) OpsOf(t EventType) []string

OpsOf is the package OpsOf under this plugin's mapping: the operations mapped to t, plus t itself read as an operation id unless that id is mapped to another type. Before Init it is the package OpsOf.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) RateEvent

func (p *Plugin) RateEvent(ctx context.Context, c *auth.Call, re auth.RateEvent)

RateEvent is auth.RateEventListener: a call over a rate rule's limit becomes one event (TypeRateLimitExceeded) with Code RateCodePrefix plus the outcome, OK when the call went through (logged, challenge passed). Only the first event of a rule's window is written (auth.RateEvent.FirstInWindow), so a flood against one key cannot fill the table: one row per window, as the core's log line. It runs before the operation's transaction, so the row is written directly; a failure is logged and never changes the answer. A skipped operation (Skip) records none.

func (*Plugin) Record

func (p *Plugin) Record(ctx context.Context, e Event) error

Record writes an event for something that happened outside the pipeline: a Go-only call from trusted code, such as admin.BanFor during an import, which no operation hook sees. The caller fills Op, Plugin and the ids; At defaults to now and Transport to auth.TransportGo. It joins the transaction in ctx, so a refused import records nothing.

func (*Plugin) Sweep

func (p *Plugin) Sweep(ctx context.Context, _ time.Time) (int64, error)

Sweep deletes events older than the retention. The core's cutoff is for spent tokens and is ignored: an audit log keeps its own, longer, memory.

func (*Plugin) Tables

func (p *Plugin) Tables() []sqlb.Table

Tables declares the events table, unless a Sink replaces it.

func (*Plugin) TypeOf

func (p *Plugin) TypeOf(op auth.EndpointID) EventType

TypeOf is the event type of op under this plugin's mapping (the built-in one, EventTyper plugins and Types); see the package TypeOf for the rest. Before Init it is the package TypeOf.

func (*Plugin) TypedMigrations

func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)

TypedMigrations creates the events table for engine. No foreign key to auth_user: an event about a deleted user is exactly what an investigation needs to keep, and a ban's row must outlive the account it ended.

type Querier

type Querier interface {
	Events(ctx context.Context, q Query) (Page, error)
}

Querier is the read side of a Sink, an optional upgrade: a sink that also implements it serves Events, my-events and the admin plugin's audit route from wherever it keeps events; one that does not makes them answer ErrNoQueryBackend. The query arrives with Page and Limit already resolved (at least 1, at most MaxPageSize). Matching follows Query's rules: UserID is the actor or the subject; Type matches the Type the event was recorded with, which Record sets before the sink sees it, so storing it is enough. An event returned with no Type gets one from the plugin's mapping.

type Query

type Query struct {
	UserID     string          `json:"userId,omitempty"`
	Identifier string          `json:"identifier,omitempty"`
	Op         auth.EndpointID `json:"op,omitempty"`
	// OrgID narrows to one organization's events; Type to one kind of
	// event (TypeOf), across every operation that produces it.
	OrgID string    `json:"orgId,omitempty"`
	Type  EventType `json:"type,omitempty"`
	// Severity narrows to exactly one severity; MinSeverity to that
	// severity and above ("warn" is warn and alert).
	Severity    Severity `json:"severity,omitempty"`
	MinSeverity Severity `json:"minSeverity,omitempty"`
	Page        int      `json:"page,omitempty,string"`
	Limit       int      `json:"limit,omitempty,string"`
}

Query selects events. UserID matches the actor or the subject, so one query answers "everything about this user"; Identifier matches what an anonymous call named, as stored (normalized), so "every sign-in attempt at this address" is one query whether or not the address has an account — a failed attempt carries no user id on purpose, since resolving one would make the row differ between a known and an unknown account; Op narrows to one operation. Page starts at 1; Limit defaults to auth.WithDefaultPageSize, else DefaultPageSize, and is clamped to MaxPageSize. Strings in JSON, because a Query operation's input arrives as URL values.

type Redactor

type Redactor interface {
	Redact(ctx context.Context, e Event) Event
}

Redactor removes personal data from an event before it is stored or sent to a Sink. It runs on every write path (the pipeline hooks, rate-limit events and Record), after the event's severity was decided from the full event, so a rule such as "alert on this IP range" still sees the real address.

The returned event replaces the original, with one invariant the plugin enforces: only the personal fields can change (ActorID, SessionID, SubjectID, ImpersonatorID, Identifier, IP, UserAgent, OrgID). ID, At, Op, Plugin, Transport, OK, Code, Severity and Type are restored from the original, so a redactor cannot make a failure look like a success or move an event to another operation. It must be deterministic enough to run twice safely and must not retain e.

var MaskPIIRedactor Redactor = RedactorFunc(func(_ context.Context, e Event) Event { return MaskPII(e) })

MaskPIIRedactor is MaskPII as a Redactor, ready for Redact.

type RedactorFunc

type RedactorFunc func(ctx context.Context, e Event) Event

RedactorFunc adapts a function to Redactor.

func (RedactorFunc) Redact

func (f RedactorFunc) Redact(ctx context.Context, e Event) Event

Redact calls f.

type Severity

type Severity = auth.SecuritySeverity

Severity ranks an event so a review can start from the rows that matter. It is the core's auth.SecuritySeverity, one scale for the security event stream and the audit log, so a sink that pages on "alert" means the same thing in both.

func DefaultSeverity

func DefaultSeverity(e Event) Severity

DefaultSeverity is the severity an event gets when the app has mapped nothing: a rate-limit event is warn when the call was blocked or challenged and info when it was only logged or the challenge was passed; a failed call is warn; an administrator's successful action on another user is alert for the destructive or takeover kinds (ban, delete, impersonate, set a password) and warn for the rest; everything else is info. A failure is never above warn, since a refused action changed nothing.

It reads only the event, so it gives the same answer in a sink or a report as at write time.

type Sink

type Sink interface {
	Record(ctx context.Context, e Event) error
}

Sink receives events instead of the table (To). Record is called inside the operation's transaction for a success, so an error refuses the operation: an app that cannot record a sign-in does not grant it. For a failure it is called after the rollback and an error is logged, since the operation has already failed.

type SinkFunc

type SinkFunc func(ctx context.Context, e Event) error

SinkFunc adapts a function to Sink.

func (SinkFunc) Record

func (f SinkFunc) Record(ctx context.Context, e Event) error

Record calls f.

type Targeted

type Targeted interface {
	TargetUserID() string
}

Targeted is an operation input that names the user the call is about, such as an admin's ban request. Implement it on a request type and the event's SubjectID is filled; no import of this package is needed.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL