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
- Variables
- func LinkedOp(provider string) auth.EndpointID
- func OpsOf(t EventType) []string
- type Event
- type EventType
- type EventTyper
- type MyQuery
- type Option
- func Queries() Option
- func Redact(r Redactor) Option
- func Retention(d time.Duration) Option
- func Severities(m map[auth.EndpointID]Severity) Option
- func SeverityFunc(f func(e Event, current Severity) Severity) Option
- func Skip(ids ...auth.EndpointID) Option
- func To(s Sink) Option
- func Types(m map[auth.EndpointID]EventType) Option
- type OrgScoped
- type Page
- type Plugin
- func (p *Plugin) Events(ctx context.Context, q Query) (Page, error)
- func (p *Plugin) Init(a *auth.Auth) error
- func (p *Plugin) MyEvents(ctx context.Context, q MyQuery) (Page, error)
- func (p *Plugin) Name() string
- func (p *Plugin) OperationHooks() []auth.OperationHook
- func (p *Plugin) Operations() []auth.Operation
- func (p *Plugin) OpsOf(t EventType) []string
- func (p *Plugin) OptionErr() error
- func (p *Plugin) RateEvent(ctx context.Context, c *auth.Call, re auth.RateEvent)
- func (p *Plugin) Record(ctx context.Context, e Event) error
- func (p *Plugin) Sweep(ctx context.Context, _ time.Time) (int64, error)
- func (p *Plugin) Tables() []sqlb.Table
- func (p *Plugin) TypeOf(op auth.EndpointID) EventType
- func (p *Plugin) TypedMigrations(engine string, t auth.SQLTypes) ([]auth.Migration, error)
- type Querier
- type Query
- type Redactor
- type RedactorFunc
- type Severity
- type Sink
- type SinkFunc
- type Targeted
Constants ¶
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.
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.
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.
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.
const EndpointMyEvents auth.EndpointID = "audit.my-events"
EndpointMyEvents is the self-service history operation's id.
const PluginName = "audit"
PluginName is the plugin's permanent identity and its migration owner.
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 ¶
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.
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.
var EventSeverity = sqlb.Col[string](TableEvent, ColSeverity)
EventSeverity is the typed severity column.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 (*Plugin) Events ¶
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 ¶
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 ¶
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) 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 ¶
Operations serves my-events.
func (*Plugin) OpsOf ¶
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 ¶
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 ¶
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 ¶
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 ¶
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) 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 ¶
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 ¶
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 ¶
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.
type RedactorFunc ¶
RedactorFunc adapts a function to Redactor.
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 ¶
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 ¶
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.