Documentation
¶
Overview ¶
Package audit writes the append-only event log.
Append-only is enforced at the database level: the application role holds INSERT on audit_events and neither UPDATE nor DELETE (R-027). Every authorization denial is audited, not only successes, and the event is written before the privileged action, not after — an exec session that fails to open is still recorded as attempted (R-228).
Index ¶
- Constants
- Variables
- func Changes(before, after any) map[string]any
- func ManifestName(objectName string) string
- func PageSize(requested int) int
- func Str(p *string) string
- func StreamPageSize(requested int) int
- func Verify(ctx context.Context, st Store, rec ArchiveRecord) error
- type ArchiveMode
- type ArchiveRecord
- type Archiver
- type Archives
- type Cursor
- type Encoder
- type Event
- type Line
- type Outcome
- type PrincipalKind
- type Query
- type ReadThrottle
- type Reader
- func (r *Reader) Export(ctx context.Context, q StreamQuery, enc Encoder, write func(line []byte) error) (int, error)
- func (r *Reader) List(ctx context.Context, q Query) ([]Record, error)
- func (r *Reader) Now(ctx context.Context) (Cursor, error)
- func (r *Reader) Oldest(ctx context.Context) (time.Time, bool, error)
- func (r *Reader) ResolveCursor(ctx context.Context, s string) (Cursor, error)
- func (r *Reader) Stream(ctx context.Context, q StreamQuery) (StreamPage, error)
- func (r *Reader) Wait(ctx context.Context, q StreamQuery, wait time.Duration, c clock.Clock) (StreamPage, error)
- type Record
- type Retention
- type Store
- type Stores
- type StreamPage
- type StreamQuery
- type Writer
Constants ¶
const CursorNow = "now"
CursorNow is the wire value meaning "after the newest settled event".
const DefaultArchiveInterval = 24 * time.Hour
DefaultArchiveInterval is how often retention runs. Months turn over once a month; a daily pass finds each one within a day and costs one small query on the days there is nothing to do.
const DefaultReadEvery = time.Hour
DefaultReadEvery is how often one reader's stream reads are recorded.
const DefaultRetentionMonths = 3
DefaultRetentionMonths is retention when policy does not say (R-348).
const MaxStreamWait = 60 * time.Second
MaxStreamWait bounds a long poll.
const MinRetentionMonths = 3
MinRetentionMonths is the floor (R-348): no month leaves the live log until it ended at least this long ago, whatever policy says. audit_drop_month holds the same number in the database, so lowering this alone changes nothing.
Variables ¶
var Actions = []string{}/* 165 elements not displayed */
Actions is every action Pando writes to the audit log, sorted: the names an audit search may filter on (R-345).
From the code rather than from the log (O-54). Reading them from the log is a DISTINCT over every row, which costs what the log has grown to on each question; the names are fixed by the code that writes them, so the code is where they are listed. TestEveryActionWrittenIsCatalogued fails when a literal action written anywhere in the tree is missing here.
Functions ¶
func Changes ¶
Changes describes what changed between two versions of a document, for an audit event's detail (R-390): "changed" lists the paths of the fields that differ, and "before" and "after" hold their values where a value is a scalar. A list or an object that changed is named and not copied: the path is what an investigation searches for, and copying whole structures would make one policy edit the largest row in the log.
Both sides are read through their JSON encoding, so a secret.Value — which encodes as "[redacted]" — is the same on both sides and its change is never recorded as a value (R-194). A field holding a secret's value changes as a path alone at most.
Nil when nothing changed or either side cannot be encoded.
func ManifestName ¶
ManifestName is the manifest's object name beside an archive.
func PageSize ¶
PageSize is how many records a Query asking for `requested` will return.
Exported because the API layer has to know it to decide whether a page was full, and a function rather than the constant it used to export because knowing the default is not enough: a caller asking for 1000 got 500 records and a layer above comparing them against 1000, so the page never looked full and the cursor for the next page was never sent. Asking the same code that does the clamping is the only version of this that cannot drift.
It is also what keeps the allocation below bounded by a constant rather than by whatever number arrived in a query string.
func StreamPageSize ¶
StreamPageSize is how many events a StreamQuery asking for `requested` scans.
func Verify ¶
func Verify(ctx context.Context, st Store, rec ArchiveRecord) error
Verify reads an archive back and checks it against its record: the stored bytes' size and digest, that it decompresses, and that it holds RowCount events in id order from FirstID to LastID. Nothing is removed from the live log until this passes (R-347).
Types ¶
type ArchiveMode ¶
type ArchiveMode string
ArchiveMode is what happens to a month past retention.
const ( // ArchiveKeep writes the archive under Pando's own directory. The default. ArchiveKeep ArchiveMode = "keep" // ArchiveExport writes it to a backup destination (R-217). ArchiveExport ArchiveMode = "export" // ArchiveOff archives nothing, so nothing leaves the live log: the log // grows as it did before retention existed. ArchiveOff ArchiveMode = "off" )
func (ArchiveMode) Valid ¶
func (m ArchiveMode) Valid() error
Valid refuses anything but the three modes. Empty is unset, which is keep.
type ArchiveRecord ¶
type ArchiveRecord struct {
ID string `json:"id"`
Month string `json:"month"` // 2026-06
AdapterRef string `json:"adapter_ref,omitempty"`
ObjectName string `json:"object_name"`
RowCount int64 `json:"row_count"`
FirstID int64 `json:"first_id"`
LastID int64 `json:"last_id"`
FirstAt time.Time `json:"first_at"`
LastAt time.Time `json:"last_at"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}
ArchiveRecord is one archived month: where it is and what is in it.
The manifest R-347 asks for. A copy is written beside the archive, so an exported archive says what it holds without Pando's database.
type Archiver ¶
type Archiver struct {
Pool *pgxpool.Pool
Stores Stores
Retention func(ctx context.Context) (Retention, error)
Clock clock.Clock
Logger *zap.Logger
Interval time.Duration
// Holds names the enabled audit sinks that have not been sent every event
// in [lo, hi) (R-386). A month any of them holds is neither archived nor
// removed this pass: archiving it now would record an archive for rows
// still in the live log. Nil holds nothing.
Holds func(ctx context.Context, lo, hi time.Time) ([]string, error)
// Audit records that a month was held, once per month per process. The
// archiver's own role cannot write the log, so this is the application's
// writer. Nil records nothing.
Audit func(ctx context.Context, e Event)
// contains filtered or unexported fields
}
Archiver runs retention. Pool must be held as the archiver role (state.ArchiverRole); the application role cannot do any of this, by design.
type Archives ¶
Archives lists and opens archives for the API (R-261). It reads with the application role, which may read the record and write none of it.
func (*Archives) List ¶
func (a *Archives) List(ctx context.Context) ([]ArchiveRecord, error)
List is every archive, newest month first.
func (*Archives) Open ¶
func (a *Archives) Open(ctx context.Context, archiveID string) (ArchiveRecord, io.ReadCloser, error)
Open returns an archive's record and its bytes, from wherever it was written. The caller closes the reader.
type Cursor ¶
Cursor is a position in the stream: the last event read. The zero Cursor is before the first event in the live log.
func ParseCursor ¶
ParseCursor reads a cursor as String writes it. Empty is the zero Cursor. "now" is not a cursor and is resolved by Reader.Now.
type Encoder ¶
type Encoder func(line json.RawMessage) (json.RawMessage, error)
Encoder turns a native line into another format, such as OCSF.
type Event ¶
type Event struct {
PrincipalKind PrincipalKind
PrincipalID string
// OnBehalfOf is the owning user when a delegated token acted (R-229).
// Authorization already resolved through the owner; the audit log records
// both so "what did this person do" stays answerable.
OnBehalfOf string
Action string
AppID string
TargetKind string
TargetID string
RequestID string
Detail map[string]any
// Outcome is success, denied or failed (R-379). Empty derives it from
// the action (OutcomeOf), which is right for every event whose action
// name already says how it ended. A finished deploy, whose one action
// covers both, sets it.
Outcome Outcome
}
Event is one audit record.
Nothing here is ever updated or deleted — the database enforces that, not this type (R-027). Detail must never contain a secret value; secret.Value makes that structural on the way in.
type Line ¶
type Line struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurred_at"`
PrincipalKind string `json:"principal_kind"`
PrincipalID *string `json:"principal_id"`
OnBehalfOf *string `json:"on_behalf_of"`
Action string `json:"action"`
AppID *string `json:"app_id"`
TargetKind *string `json:"target_kind"`
TargetID *string `json:"target_id"`
RequestID *string `json:"request_id"`
Detail map[string]any `json:"detail"`
SchemaVersion *int `json:"schema_version"`
Outcome *string `json:"outcome"`
SourceIP *string `json:"source_ip"`
PeerIP *string `json:"peer_ip"`
UserAgent *string `json:"user_agent"`
ActorName *string `json:"actor_name"`
ActorEmail *string `json:"actor_email"`
}
Line is one native line (design 12 §6.1) decoded: the row as row_to_json writes it, every column present and null where unset. What the OCSF mapper and anything else re-encoding the stream reads.
type PrincipalKind ¶
type PrincipalKind string
PrincipalKind identifies who acted.
const ( KindUser PrincipalKind = "user" KindToken PrincipalKind = "token" KindSystem PrincipalKind = "system" KindAnonymous PrincipalKind = "anonymous" )
type Query ¶
type Query struct {
// Action matches a prefix, so "app." finds every app event and
// "session.denied" finds exactly one kind. A prefix rather than a substring
// because actions are dotted namespaces — a substring match would make
// "grant.delete" a result for a search for "delete".
Action string
// Actions are more prefixes, any of which matches, alongside Action:
// "apps someone added or deleted" is app.create or app.delete, and filters
// otherwise combine with AND.
Actions []string
AppID string
PrincipalID string
// PrincipalKind narrows to one kind of actor: user, token, system or
// anonymous. The only way to find anonymous events, which have no ID, and
// every system process at once rather than one at a time.
PrincipalKind string
// TargetKind and TargetID narrow to what the action was done to — "every
// change to this user", "everything done to any role". Exact matches.
TargetKind string
TargetID string
// Involving narrows to events where one ID is on either side: the actor
// (or the person a token acted for) or the target. "Everything to do with
// this account" — what it did and what was done to it — which the actor and
// target filters cannot say, because filters combine with AND.
Involving string
// Since and Until bound when it happened: Since inclusive, Until
// exclusive, so consecutive ranges neither overlap nor leave a gap. Zero
// means unbounded.
Since time.Time
Until time.Time
// Limit defaults to 100, capped at 500. PageSize is the clamp.
Limit int
// Cursor is the ID of the last record on the previous page. The log is
// append-only with monotonic IDs, so "before this ID" is a stable page
// boundary in a way an offset is not — with an offset, events arriving
// between requests shift every later page.
Cursor int64
}
Query narrows a read. Every field is optional; the zero value reads the whole log, newest first.
type ReadThrottle ¶
ReadThrottle decides when a read of the audit stream is itself audited (R-388): at most once per reader per Every, per replica. A collector long-polling every two seconds would otherwise put tens of thousands of events a day about itself into the log it is reading. Listing, exporting and downloading an archive are audited every time and do not use it.
type Reader ¶
type Reader struct {
// contains filtered or unexported fields
}
Reader queries the audit log (R-227, R-229).
A separate type from Writer, because they are separate privileges: writing is something every code path does, and reading is gated by install.audit.read. Neither can update or delete — the database refuses both to the application role (R-027), so this type could not offer it even if someone added a method.
func (*Reader) Export ¶
func (r *Reader) Export(ctx context.Context, q StreamQuery, enc Encoder, write func(line []byte) error) (int, error)
Export writes every settled event in [q.Since, q.Until), in commit order, one line each, through enc (nil for native). It pages from a cursor, so an export of a year is never held in memory.
func (*Reader) Now ¶
Now is the cursor after the newest settled event: what a reader that wants only what happens next starts from.
func (*Reader) Oldest ¶
Oldest is when the oldest event in the live log happened, and false when it is empty. An export reaching before it says so (R-387).
func (*Reader) ResolveCursor ¶
ResolveCursor reads a cursor from the wire, "now" included.
func (*Reader) Stream ¶
func (r *Reader) Stream(ctx context.Context, q StreamQuery) (StreamPage, error)
Stream reads one page past q.After.
func (*Reader) Wait ¶
func (r *Reader) Wait(ctx context.Context, q StreamQuery, wait time.Duration, c clock.Clock) (StreamPage, error)
Wait reads like Stream, but when nothing settled lies past the cursor it looks again every second until something does or wait runs out, and then returns the empty page with the cursor unchanged.
type Record ¶
type Record struct {
ID int64 `json:"id"`
OccurredAt time.Time `json:"occurred_at"`
PrincipalKind string `json:"principal_kind"`
PrincipalID string `json:"principal_id,omitempty"`
OnBehalfOf string `json:"on_behalf_of,omitempty"`
Action string `json:"action"`
AppID string `json:"app_id,omitempty"`
TargetKind string `json:"target_kind,omitempty"`
TargetID string `json:"target_id,omitempty"`
RequestID string `json:"request_id,omitempty"`
Detail map[string]any `json:"detail,omitempty"`
// What R-379 adds. Empty on events written before it.
Outcome string `json:"outcome,omitempty"`
SourceIP string `json:"source_ip,omitempty"`
PeerIP string `json:"peer_ip,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ActorName string `json:"actor_name,omitempty"`
ActorEmail string `json:"actor_email,omitempty"`
SchemaVersion int `json:"schema_version"`
}
Record is one stored event, as read back.
type Retention ¶
type Retention struct {
// Months the live log keeps. Zero is DefaultRetentionMonths.
Months int
// Archive is the mode. Empty is ArchiveKeep.
Archive ArchiveMode
// Destination is the backup adapter an export goes to. Empty is the
// default backup destination.
Destination string
}
Retention is host policy's word on the audit log.
func (Retention) Mode ¶
func (r Retention) Mode() ArchiveMode
Mode is the archive mode with the default applied.
func (Retention) MonthsKept ¶
MonthsKept is retention with the default applied and the floor held.
type Store ¶
type Store interface {
Writer(ctx context.Context, name string) (io.WriteCloser, error)
Reader(ctx context.Context, name string) (io.ReadCloser, error)
}
Store is somewhere an archive can be written and read back. A backup adapter is one (R-217); so is the directory Pando keeps archives in.
type Stores ¶
type Stores struct {
// Kept is Pando's own directory, for ArchiveKeep.
Kept Store
// Export resolves a backup adapter by reference, empty meaning the
// default, and returns the reference it resolved to.
Export func(ref string) (Store, string, error)
}
Stores resolves where an archive lives.
type StreamPage ¶
type StreamPage struct {
// Events are native lines (design 12 §6.1): each row as the archiver
// writes it, oldest first.
Events []json.RawMessage
// IDs are the events' ids, in the same order.
IDs []int64
// Cursor is where the next read starts. It has moved past every event
// scanned, including those a filter dropped.
Cursor Cursor
// CaughtUp says the read reached the horizon: nothing settled lies past
// Cursor right now.
CaughtUp bool
}
StreamPage is what one read returns.
type StreamQuery ¶
type StreamQuery struct {
After Cursor
// Limit defaults to 500, capped at 1000 (StreamPageSize).
Limit int
// Actions and Exclude are prefixes. An event is read when it matches one
// of Actions (or Actions is empty) and none of Exclude. The cursor moves
// past the events a filter drops, so a filtered reader never reads them
// again.
Actions []string
Exclude []string
// Since and Until bound occurred_at, Since inclusive and Until exclusive,
// for an export of a range. Zero is unbounded.
Since time.Time
Until time.Time
}
StreamQuery is one page of the stream.
type Writer ¶
type Writer struct {
// contains filtered or unexported fields
}
Writer appends to the audit log.
func (*Writer) Write ¶
Write appends an event.
Callers write the event *before* the privileged action, not after (R-228): an exec session that fails to open is still recorded as attempted. Denials are audited as well as successes — a denial pattern is the signal that matters for detecting misuse, and it is the thing most commonly left out.