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

audit

package
v1.10.0 Latest Latest
Warning

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

Go to latest
Published: Nov 3, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package audit defines the core audit event types, query types, and the event store interface for Chronicle.

Event

Event is the central type in Chronicle. It is an append-only record of something that happened in your system. Events are immutable once written — there is no Update or Delete path for individual events.

Event fields are organised into seven groups:

  • Identity: ID (TypeID with "audit_" prefix), Timestamp, Sequence (monotonically increasing per stream)
  • Hash chain: Hash (SHA-256 of this event), PrevHash (hash of predecessor), StreamID (the chain this event belongs to)
  • Scope: AppID (required), TenantID, UserID, IP — automatically applied from the context by chronicle.Record
  • Action: Action (verb, e.g. "login"), Resource (noun, e.g. "session"), Category (logical group, e.g. "auth")
  • Details: ResourceID, Metadata (arbitrary key-value pairs), Outcome, Severity, Reason
  • GDPR: SubjectID (data subject for crypto-erasure), EncryptionKeyID
  • Erasure state: Erased, ErasedAt, ErasureID — set by the erasure engine, never by the caller

Severity

Three severity constants are defined:

Outcome

Three outcome constants are defined:

Queries

Query accepts filters for time range, severity, outcome, category, user, resource, and pagination (Limit, Offset, Order). QueryResult carries the matching Event slice and a Total count.

AggregateQuery groups events by a chosen field and returns counts via AggregateResult. CountQuery returns a scalar count without fetching events.

TimeRange is a simple From/To pair used by Store.ByUser.

Store

Store is the event persistence interface. It is append-only by design:

All backends (Postgres, Bun, SQLite, Memory) implement this interface and are combined into the composite [store.Store] interface alongside the other sub-interfaces.

Package audit defines the core audit event types and store interface.

Index

Constants

View Source
const (
	SeverityInfo     = "info"
	SeverityWarning  = "warning"
	SeverityCritical = "critical"
)

Severity levels for audit events.

View Source
const (
	OutcomeSuccess = "success"
	OutcomeFailure = "failure"
	OutcomeDenied  = "denied"
)

Outcome values for audit events.

View Source
const ActionRetentionPurge = "retention.purge"

ActionRetentionPurge is the action of a retention record.

View Source
const CategoryRetention = "chronicle.retention"

CategoryRetention is the reserved category of retention records.

A retention record is an ordinary event in the stream whose events a policy removed. It lists what was removed, so verification can tell a sequence retention deleted from one an attacker deleted. Because it is hashed into the chain like every other event, it is exactly as hard to forge as the chain it explains: keyed when the chain is keyed, and covered by a signed checkpoint when checkpoints are on.

Retention never selects an event in this category. A record that could be purged would turn everything it explains back into unexplained gaps.

View Source
const DefaultUserQueryLimit = 1000

DefaultUserQueryLimit bounds Store.ByUser when TimeRange.Limit is unset, so a lookup against a high-traffic subject cannot load the whole table.

View Source
const MaxRetentionEntries = 256

MaxRetentionEntries bounds how many removed events one record lists, so a large purge becomes several modest events rather than one enormous one.

Variables

View Source
var (
	// ErrEmptyGroupBy is returned when an aggregate query names no fields.
	ErrEmptyGroupBy = errors.New("aggregate query requires at least one group_by field")

	// ErrUnsupportedGroupBy is returned when a group_by field is not one of the
	// fields an aggregate query is allowed to group on.
	ErrUnsupportedGroupBy = errors.New("unsupported group_by field")

	// ErrDuplicateGroupBy is returned when the same field is named twice.
	ErrDuplicateGroupBy = errors.New("duplicate group_by field")

	// ErrMultipleBucketFields is returned when a query names more than one
	// time bucket field ("day" and "hour"). An AggregateGroup has exactly one
	// Bucket field, so grouping by two time units at once is not expressible:
	// both route through groupFieldPointer to the same &g.Bucket, and the
	// second assignment would silently overwrite the first rather than
	// producing two columns. This is distinct from ErrDuplicateGroupBy, which
	// catches the same field named twice ("day", "day"); here the field names
	// differ but the underlying column and destination field collide.
	ErrMultipleBucketFields = errors.New("group_by names more than one time bucket field")
)

Errors returned when validating an aggregate query's grouping fields.

View Source
var BucketFields = []string{"day", "hour"}

BucketFields are the group_by fields that select a time bucket rather than a column. Each backend renders these as its own truncation expression, so groupByColumns maps them to the timestamp column they truncate.

View Source
var ErrNotRetentionRecord = errors.New("audit: not a retention record")

ErrNotRetentionRecord is returned by ParseRetentionRecord for an event that is not a well-formed retention record.

Functions

func AssignGroupValue

func AssignGroupValue(g *AggregateGroup, field, value string) error

AssignGroupValue sets the AggregateGroup field named by field to value. It returns ErrUnsupportedGroupBy for any field outside the whitelist.

func GroupFieldPointer

func GroupFieldPointer(g *AggregateGroup, field string) (*string, error)

GroupFieldPointer returns a pointer to the AggregateGroup field named by field, for use as a row-scan destination.

func IsBucketField

func IsBucketField(field string) bool

IsBucketField reports whether a group_by field is a time bucket.

func IsRetentionRecord

func IsRetentionRecord(e *Event) bool

IsRetentionRecord reports whether an event claims to be a retention record. It says nothing about whether the claim is genuine; only verifying the event's digest does that.

func ResolveGroupBy

func ResolveGroupBy(fields []string) ([]string, error)

ResolveGroupBy validates the requested group_by fields and returns the physical column names to group on, in the order requested.

Validation happens before any query is built. Matching is exact: no trimming and no case folding, so a field either is a known identifier or is rejected.

Types

type AggregateGroup

type AggregateGroup struct {
	Category string `json:"category,omitempty"`
	Action   string `json:"action,omitempty"`
	Outcome  string `json:"outcome,omitempty"`
	Severity string `json:"severity,omitempty"`
	Resource string `json:"resource,omitempty"`

	// Bucket is the time bucket this group covers, when the query grouped by
	// "day" or "hour". It is RFC3339, truncated to the bucket: "2026-09-23"
	// for a day, "2026-09-23T14:00:00Z" for an hour.
	//
	// A period in which nothing was recorded produces NO group at all rather
	// than a group with Count 0. That absence is the point: a gap in an audit
	// series is itself a finding, and the dashboard renders it as one.
	Bucket string `json:"bucket,omitempty"`

	Count int64 `json:"count"`
}

AggregateGroup is a single group in an aggregation result.

type AggregateQuery

type AggregateQuery struct {
	After    time.Time `json:"after"`
	Before   time.Time `json:"before"`
	AppID    string    `json:"app_id,omitempty"`
	TenantID string    `json:"tenant_id,omitempty"`
	GroupBy  []string  `json:"group_by"` // "category", "action", "outcome", "severity", "resource"
}

AggregateQuery defines parameters for grouped event statistics.

type AggregateResult

type AggregateResult struct {
	Groups []AggregateGroup `json:"groups"`
	Total  int64            `json:"total"`
}

AggregateResult is the result of an aggregation query.

type CountQuery

type CountQuery struct {
	After    time.Time `json:"after"`
	Before   time.Time `json:"before"`
	AppID    string    `json:"app_id,omitempty"`
	TenantID string    `json:"tenant_id,omitempty"`
	Category string    `json:"category,omitempty"`
}

CountQuery defines filters for counting events.

type Event

type Event struct {
	// Identity
	ID        id.ID     `json:"id"`
	Timestamp time.Time `json:"timestamp"`
	Sequence  uint64    `json:"sequence"`

	// Hash chain
	Hash     string `json:"hash"`
	PrevHash string `json:"prev_hash"`
	StreamID id.ID  `json:"stream_id"`

	// Scope (automatically extracted from forge.Scope in ctx)
	AppID    string `json:"app_id"`
	TenantID string `json:"tenant_id,omitempty"`
	UserID   string `json:"user_id,omitempty"`
	IP       string `json:"ip,omitempty"`

	// Request correlation. UserAgent is personal data and is sealed with IP;
	// RequestID and SessionID are opaque identifiers used to tie an event to
	// the HTTP request and the authenticated session that produced it.
	UserAgent string `json:"user_agent,omitempty"`
	RequestID string `json:"request_id,omitempty"`
	SessionID string `json:"session_id,omitempty"`

	// What happened
	Action   string `json:"action"`
	Resource string `json:"resource"`
	Category string `json:"category"`

	// Details
	ResourceID string         `json:"resource_id,omitempty"`
	Metadata   map[string]any `json:"metadata,omitempty"`
	Outcome    string         `json:"outcome"`
	Severity   string         `json:"severity"`
	Reason     string         `json:"reason,omitempty"`

	// ExactMetadata selects lossless JSON number decoding for reliable records.
	// Assigned by Chronicle before persistence, preserved by archives.
	ExactMetadata bool `json:"exact_metadata,omitempty"`

	// GDPR (crypto-erasure support)
	SubjectID       string `json:"subject_id,omitempty"`
	EncryptionKeyID string `json:"encryption_key_id,omitempty"`

	// Hash scheme provenance. HashScheme names the digest scheme this event was
	// written under and HashKeyID the key generation, so verification
	// reproduces what was written rather than what the current config would
	// write. Both empty means a row written before schemes were recorded.
	HashScheme string `json:"hash_scheme,omitempty"`
	HashKeyID  string `json:"hash_key_id,omitempty"`

	// Erasure state (set by retention/GDPR engine, never by caller)
	Erased    bool       `json:"erased,omitempty"`
	ErasedAt  *time.Time `json:"erased_at,omitempty"`
	ErasureID string     `json:"erasure_id,omitempty"`
}

Event is the universal audit event format. Append-only — once recorded, events are immutable.

func NewRetentionRecord

func NewRetentionRecord(ref RetentionRef, streamID id.ID, entries []RetentionEntry) *Event

NewRetentionRecord builds the event that records a retention purge. The caller appends it through the chain writer, which assigns its identity, sequence and digest.

Entries are encoded as "seq:prev_hash:hash" strings rather than objects. Metadata is hashed after a round trip through whatever the backend stores it as, and a JSON number can come back as a float64 where it went in as a uint64. A string comes back as the same string.

type Query

type Query struct {
	// Time range
	After  time.Time `json:"after"`
	Before time.Time `json:"before"`

	// Scope (auto-set from forge.Scope if not explicit)
	AppID    string `json:"app_id,omitempty"`
	TenantID string `json:"tenant_id,omitempty"`
	UserID   string `json:"user_id,omitempty"`

	// Correlation filters. SessionID returns everything one authenticated
	// session did; RequestID returns everything one HTTP request did.
	SessionID string `json:"session_id,omitempty"`
	RequestID string `json:"request_id,omitempty"`

	// Filters
	Categories []string `json:"categories,omitempty"`
	Actions    []string `json:"actions,omitempty"`
	Resources  []string `json:"resources,omitempty"`
	Severity   []string `json:"severity,omitempty"`
	Outcome    []string `json:"outcome,omitempty"`

	// Pagination
	Limit  int    `json:"limit"`
	Offset int    `json:"offset"`
	Order  string `json:"order"` // "asc" or "desc"
}

Query defines filters for querying audit events.

type QueryResult

type QueryResult struct {
	Events  []*Event `json:"events"`
	Total   int64    `json:"total"`
	HasMore bool     `json:"has_more"`
}

QueryResult is the paginated result of an event query.

type RetentionEntry

type RetentionEntry struct {
	Seq      uint64 `json:"seq"`
	PrevHash string `json:"prev_hash"`
	Hash     string `json:"hash"`
}

RetentionEntry is one removed event, reduced to exactly what the chain needs to link across it: its position and the two hashes either side of it.

The event's own digest cannot be recomputed once its content is gone, since the digest covers nearly every field. What survives is linkage: the predecessor's Hash must equal PrevHash, and the successor's PrevHash must equal Hash. The retention record's own digest is what vouches for these values.

type RetentionRecord

type RetentionRecord struct {
	Ref      RetentionRef
	StreamID string
	Entries  []RetentionEntry
}

RetentionRecord is a parsed retention record.

func ParseRetentionRecord

func ParseRetentionRecord(e *Event) (*RetentionRecord, error)

ParseRetentionRecord decodes a retention record's metadata.

type RetentionRef

type RetentionRef struct {
	PolicyID string `json:"policy_id"`
	Category string `json:"category"`

	// Backfill names the archive a backfilled record was recovered from, and
	// is empty on a record the enforcer wrote at purge time. A backfilled
	// record explains a purge that happened before retention records existed:
	// every entry in it was checked against an archived copy of the event,
	// whose keyed digest was recomputed and whose hashes link to the events
	// either side of it. See Chronicle.BackfillRetention.
	Backfill string `json:"backfill,omitempty"`
}

RetentionRef identifies the policy a retention record acted under.

type Store

type Store interface {
	// Append persists a single event.
	//
	// Backends that recompute the digest (the SQL ones re-derive sequence and
	// prev_hash under a row lock) also stamp Event.HashScheme with the scheme
	// they used. The rest store exactly what they are handed, so a caller
	// reaching Append directly owns both fields: an event carrying a Hash with
	// no HashScheme is a row verification can only resolve through the
	// pre-migration fallback, which recognises the retired schemes alone and
	// will reject a digest computed under a current one.
	//
	// Chronicle.Record sets both. This only bites callers bypassing it.
	Append(ctx context.Context, event *Event) error

	// AppendBatch persists a batch of events atomically.
	AppendBatch(ctx context.Context, events []*Event) error

	// Get returns a single event by ID.
	Get(ctx context.Context, eventID id.ID) (*Event, error)

	// Query returns events matching filters, scoped to tenant.
	Query(ctx context.Context, q *Query) (*QueryResult, error)

	// Aggregate returns grouped counts/stats for analytics.
	Aggregate(ctx context.Context, q *AggregateQuery) (*AggregateResult, error)

	// ByUser returns events for a specific user within a time range.
	ByUser(ctx context.Context, userID string, opts TimeRange) (*QueryResult, error)

	// Count returns the total number of events matching filters.
	Count(ctx context.Context, q *CountQuery) (int64, error)

	// LastSequence returns the highest sequence number for a stream.
	LastSequence(ctx context.Context, streamID id.ID) (uint64, error)

	// LastHash returns the hash of the most recent event in a stream.
	LastHash(ctx context.Context, streamID id.ID) (string, error)
}

Store is the primary event store interface. Events are append-only — no Update or Delete exists.

type TimeRange

type TimeRange struct {
	After  time.Time `json:"after"`
	Before time.Time `json:"before"`

	// Scope restricts results to one app/tenant. Leave empty only for trusted
	// in-process callers; anything serving a request should set both.
	AppID    string `json:"app_id,omitempty"`
	TenantID string `json:"tenant_id,omitempty"`

	// Limit bounds the number of events returned. Zero means
	// DefaultUserQueryLimit; a negative value means unbounded.
	Limit int `json:"limit,omitempty"`
}

TimeRange defines a time range for queries, plus the optional scope and bound that Store.ByUser applies.

A zero After or Before means "unbounded on that side". Backends must check IsZero rather than comparing against the zero time, which would otherwise filter every row out.

func DateRange

func DateRange(from, to time.Time) TimeRange

DateRange returns a TimeRange between two dates.

func Last

func Last(d time.Duration) TimeRange

Last returns a TimeRange for the last duration from now.

func LastMonth

func LastMonth() TimeRange

LastMonth returns a TimeRange for the previous month.

func LastYear

func LastYear() TimeRange

LastYear returns a TimeRange for the previous year.

func ThisMonth

func ThisMonth() TimeRange

ThisMonth returns a TimeRange from the start of this month to now.

func ThisWeek

func ThisWeek() TimeRange

ThisWeek returns a TimeRange from the start of this week (Monday) to now.

func Today

func Today() TimeRange

Today returns a TimeRange for today (midnight to now).

func Yesterday

func Yesterday() TimeRange

Yesterday returns a TimeRange for yesterday.

func (TimeRange) EffectiveLimit

func (r TimeRange) EffectiveLimit() int

EffectiveLimit returns the row bound to apply, resolving 0 to DefaultUserQueryLimit and negatives to 0 (meaning "no bound").

Jump to

Keyboard shortcuts

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