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:
- SeverityInfo — routine operational events
- SeverityWarning — anomalies that may need attention
- SeverityCritical — security events or failures requiring immediate review
Outcome ¶
Three outcome constants are defined:
- OutcomeSuccess — the action completed successfully
- OutcomeFailure — the action failed due to an error
- OutcomeDenied — the action was rejected (authorisation / policy)
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:
- Store.Append — persist a single event
- Store.AppendBatch — persist multiple events atomically
- Store.Get — retrieve one event by ID
- Store.Query — filtered, paginated event listing
- Store.Aggregate — grouped counts for analytics
- Store.ByUser — events for a specific user within a time range
- Store.Count — scalar count without fetching events
- Store.LastSequence — highest sequence number in a stream (for hash chain init)
- Store.LastHash — hash of the most recent event in a stream
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
- Variables
- func AssignGroupValue(g *AggregateGroup, field, value string) error
- func GroupFieldPointer(g *AggregateGroup, field string) (*string, error)
- func IsBucketField(field string) bool
- func IsRetentionRecord(e *Event) bool
- func ResolveGroupBy(fields []string) ([]string, error)
- type AggregateGroup
- type AggregateQuery
- type AggregateResult
- type CountQuery
- type Event
- type Query
- type QueryResult
- type RetentionEntry
- type RetentionRecord
- type RetentionRef
- type Store
- type TimeRange
Constants ¶
const ( SeverityInfo = "info" SeverityWarning = "warning" SeverityCritical = "critical" )
Severity levels for audit events.
const ( OutcomeSuccess = "success" OutcomeFailure = "failure" OutcomeDenied = "denied" )
Outcome values for audit events.
const ActionRetentionPurge = "retention.purge"
ActionRetentionPurge is the action of a retention record.
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.
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.
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 ¶
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.
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.
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 ¶
IsBucketField reports whether a group_by field is a time bucket.
func IsRetentionRecord ¶
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 ¶
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 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 (TimeRange) EffectiveLimit ¶
EffectiveLimit returns the row bound to apply, resolving 0 to DefaultUserQueryLimit and negatives to 0 (meaning "no bound").