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
- func Name(s string) string
- func NewContext(ctx context.Context, p *Pending) context.Context
- func Statement(s string) string
- func Target(r *http.Request) string
- type Event
- type FileSink
- type Kind
- type LogSink
- type Logger
- type Outcome
- type Pending
- func (p *Pending) Identity() (account, server, engine string)
- func (p *Pending) Outcome() (Outcome, string)
- func (p *Pending) Remote() string
- func (p *Pending) SetIdentity(account, server, engine string)
- func (p *Pending) SetOutcome(o Outcome, detail string)
- func (p *Pending) SetRemote(remote string)
- type Sink
Constants ¶
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 ¶
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 ¶
NewContext stashes p in ctx.
func Statement ¶
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 ¶
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 ¶
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.
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 ¶
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".
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 ¶
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) Emit ¶
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 ¶
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.
type Outcome ¶
type Outcome string
Outcome is how an event ended.
func OutcomeForStatus ¶
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 ¶
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) SetIdentity ¶
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 ¶
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.