audit

package
v1.0.0-rc.1 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package audit is TableX's audit trail: a durable, structured record of who did what, to which object, from where, and whether it worked.

It is distinct from the access log, which the server has always had. The access log answers "what HTTP traffic did this process serve" and goes to stderr with everything else; this answers "who changed my database", is emitted only for the events that matter, and is written somewhere an auditor can keep.

What is recorded, and what is never

Recorded: the identity the DATABASE reports for the connection (an account name, and on MySQL its host), the predefined server or engine it belongs to, the client address, the object acted on, the outcome, and how long it took.

Never recorded: a password, a DSN, a CSRF token, or a session id. The identity here is an account NAME — the thing a grant is written against — not a credential. Nothing in an Event can be replayed to gain access.

Off by default

With no audit destination configured, Logger is nil, Emit is a no-op on a nil receiver, and nothing is written or allocated. Turning it on is one config block.

The honest limit of the guarantee

A sink that fails is reported at ERROR level (throttled) and the request still proceeds. TableX does not refuse to serve when its audit sink is unwritable, because a full disk would then be a total outage. An operator who needs write-or-refuse semantics does not have it yet, and this comment is the place that says so rather than a doc that implies otherwise.

Index

Constants

View Source
const DefaultMaxBytes int64 = 64 << 20 // 64 MiB

DefaultMaxBytes is the rotation threshold when the operator sets none.

Variables

This section is empty.

Functions

func Name

func Name(s string) string

Name bounds a caller-supplied identity before it is recorded.

A REJECTED login carries whatever username was posted, so this field is attacker-controlled on exactly the events an auditor most needs to read. An unbounded one lets a sprayer inflate the trail at will. The cut is made valid UTF-8 again afterwards, so a name split mid-rune cannot leave a mangled byte in the record.

func NewContext

func NewContext(ctx context.Context, p *Pending) context.Context

NewContext stashes p in ctx.

func Statement

func Statement(s string) string

Statement bounds the SQL text before it is recorded, and collapses the whitespace so one statement stays one line an auditor can read.

The bound is not optional: a single INSERT from a SQL import can be megabytes, and a trail whose lines are that long is one nobody will grep. The truncation is marked, so a reader is never misled about having the whole statement — and the cut is made valid UTF-8 again, since SQL carries arbitrary string literals.

func Target

func Target(r *http.Request) string

Target names the object a request acts on, in engine-neutral dotted form ("sales", "sales.orders", "sales.public.orders"), so the trail can be queried by object instead of by URL shape.

It parses the path rather than reading ServeMux path values, because the audit event is assembled by a middleware that runs BEFORE routing — there are no path values yet. The two shapes are all TableX has:

/db/{db}[/...]
/db/{db}/table/{table}[/...]

A PostgreSQL schema arrives as a query parameter rather than a path segment, so it is spliced in between when present. Anything else — /server/*, /login — has no single object and returns "".

Types

type Event

type Event struct {
	Time    time.Time `json:"time"`
	Kind    Kind      `json:"kind"`
	Outcome Outcome   `json:"outcome"`

	// Request is the request id, shared with the access log's "id" field, so an
	// audit line can be tied back to the full HTTP context.
	Request string `json:"request,omitempty"`

	// Identity. Account is what the SERVER reports for the connection
	// ("root@localhost", "tablex"), which is the truthful answer to "whose
	// privileges did this run under" — not what was typed at the login form.
	Account string `json:"account,omitempty"`

	// Subject/Email are the PERSON, when an SSO gate is configured — the
	// provider's stable subject and the address it reported. They sit beside
	// Account rather than replacing it: the statement really did run under that
	// database account, and the value of having both is being able to join "which
	// person" to "which privileges". Empty when no gate is configured, which is
	// also how a reader tells the two deployments apart.
	Subject string `json:"subject,omitempty"`
	Email   string `json:"email,omitempty"`

	Server string `json:"server,omitempty"`
	Engine string `json:"engine,omitempty"`
	Remote string `json:"remote,omitempty"`

	// What. Target is the object in engine-neutral dotted form ("sales.orders"),
	// so the log can be queried by object rather than by URL shape.
	Method string `json:"method,omitempty"`
	Path   string `json:"path,omitempty"`
	Target string `json:"target,omitempty"`
	Status int    `json:"status,omitempty"`

	// Statement fields (KindStatement). Rows is affected rows for a mutation and
	// rows returned for a read; UserSQL marks SQL the user WROTE (the console, an
	// import) as opposed to SQL TableX generated for them (the Drop button, the
	// structure editor) — reading "DROP TABLE orders" in the trail, that is the
	// first thing worth knowing.
	Statement string `json:"statement,omitempty"`
	Rows      int64  `json:"rows,omitempty"`
	UserSQL   bool   `json:"user_sql,omitempty"`

	// Detail is a sanitized explanation when the outcome is not ok. It carries an
	// engine error message, which is why it goes through the same redaction the
	// UI uses before it reaches here.
	Detail string `json:"detail,omitempty"`

	// Millis is the duration in milliseconds.
	Millis int64 `json:"ms,omitempty"`
}

Event is one audit record. Empty fields are omitted from the JSON so a line stays readable; Time, Kind and Outcome are always present.

type FileSink

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

FileSink appends events to a file as JSON Lines: one self-contained JSON object per line, which is what every log shipper and every `jq` invocation expects, and which stays readable after a truncated write.

func OpenFile

func OpenFile(path string, maxBytes int64) (*FileSink, error)

OpenFile opens (creating if needed) an audit file. The mode is 0600: the file records account names, client addresses and — once statement auditing is on — SQL that may contain row data, so it is not world-readable. The DIRECTORY must already exist; guessing where an operator meant to keep an audit trail is not TableX's business.

func (*FileSink) Close

func (s *FileSink) Close() error

Close closes the file.

func (*FileSink) Write

func (s *FileSink) Write(e Event) error

Write appends one event.

type Kind

type Kind string

Kind classifies an event. Kept small and closed: an auditor greps on it.

const (
	// KindAuth is a login or a logout, successful or not. The most important
	// events in the log, and the only ones recorded for an unauthenticated
	// request.
	KindAuth Kind = "auth"
	// KindAction is a state-changing request: every POST that reaches a handler.
	KindAction Kind = "action"
	// KindStatement is one SQL statement TableX ran on the user's behalf.
	KindStatement Kind = "statement"
)

type LogSink

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

LogSink writes events through an *slog.Logger, which on a standard deployment means stderr — and therefore the container's log stream, journald, or whatever the operator already collects. It is how syslog is reached without TableX speaking syslog: log/syslog does not build on Windows, and a hand-rolled RFC 5424 client would be a network dependency for something every host already has a collector for.

func NewLogSink

func NewLogSink(log *slog.Logger) *LogSink

NewLogSink returns a sink over log. Events are emitted at INFO with the message "audit", so they are trivially separable from the access log's "request".

func (*LogSink) Close

func (s *LogSink) Close() error

Close is a no-op: the logger's writer is owned by the process.

func (*LogSink) Write

func (s *LogSink) Write(e Event) error

Write emits one event as structured attributes.

type Logger

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

Logger fans an event out to every configured sink. A nil *Logger is a valid disabled logger — every method tolerates it — so callers never have to guard.

func New

func New(log *slog.Logger, sinks ...Sink) *Logger

New returns a Logger over the given sinks, or nil when there are none — so "audit is off" and "audit has no destinations" are the same state, and there is no half-enabled configuration to reason about.

func (*Logger) Close

func (l *Logger) Close() error

Close closes every sink, returning the first error. Called once at shutdown.

func (*Logger) Emit

func (l *Logger) Emit(e Event)

Emit writes an event to every sink. Time is stamped here when the caller left it zero, so no caller has to remember.

func (*Logger) Enabled

func (l *Logger) Enabled() bool

Enabled reports whether events are being recorded. Callers use it to skip assembling an Event at all, not for correctness — Emit is already a no-op when disabled.

func (*Logger) Stats

func (l *Logger) Stats() (emitted, dropped int64)

Stats reports the totals since startup: events written, and writes that failed. dropped counts SINK failures, so one event failing on two sinks counts twice — the question it answers is "is the trail losing records", and it errs toward saying yes.

type Outcome

type Outcome string

Outcome is how an event ended.

const (
	OutcomeOK      Outcome = "ok"
	OutcomeDenied  Outcome = "denied" // refused by policy: auth, CSRF, throttle
	OutcomeError   Outcome = "error"
	OutcomeInvalid Outcome = "invalid" // the request was malformed or rejected
)

func OutcomeForStatus

func OutcomeForStatus(status int) Outcome

OutcomeForStatus maps an HTTP status onto an outcome, for the events where no layer said anything more specific.

401/403 are separated from the rest of the 4xx range on purpose: "refused by policy" is the class an auditor filters on, and lumping it in with a malformed form would bury it.

type Pending

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

Pending carries what is known about a request while it is still being handled.

It exists because of the shape of the middleware chain. The audit event is emitted by the OUTERMOST middleware, which is the only layer that sees the final status and the total duration — but the identity is not known until the session has been loaded several layers deeper, and each layer passes a DERIVED request whose context the outer one never sees. A pointer stashed on the way in is the one thing every layer can write to and the outer layer can still read.

It is also the hook a handler uses to say something the status code cannot: a login knows WHICH account it authenticated, and a failure knows why.

func FromContext

func FromContext(ctx context.Context) *Pending

FromContext returns the pending record for this request, or nil. A nil *Pending is usable — every method tolerates it — so no caller needs to check.

func (*Pending) Identity

func (p *Pending) Identity() (account, server, engine string)

Identity returns what has been recorded so far.

func (*Pending) Outcome

func (p *Pending) Outcome() (Outcome, string)

Outcome returns the override, if a layer set one.

func (*Pending) Remote

func (p *Pending) Remote() string

Remote returns the recorded client address.

func (*Pending) SetIdentity

func (p *Pending) SetIdentity(account, server, engine string)

SetIdentity records who the request is acting as. Called once the session is known; safe to call again if it changes mid-request, which is exactly what a login does.

func (*Pending) SetOutcome

func (p *Pending) SetOutcome(o Outcome, detail string)

SetOutcome overrides the outcome the status code would imply, and gives a reason. Use it where the status is misleading: a failed login re-renders the form with 200, which as an audit record would read as a successful one.

func (*Pending) SetRemote

func (p *Pending) SetRemote(remote string)

SetRemote records the resolved client address. It is set once, on the way in, so a statement observer deep in the driver can name the client without every layer between having to pass it along.

type Sink

type Sink interface {
	Write(Event) error
	Close() error
}

Sink is one destination for audit events. Implementations must be safe for concurrent use: every request may emit.

Jump to

Keyboard shortcuts

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