audit

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: AGPL-3.0 Imports: 20 Imported by: 0

Documentation

Overview

Package audit defines the control plane's audit-log interface (golden rule 5: every state-changing action writes an event).

Phase 0 ships only the interface and two throw-away sinks: MemorySink (tests, in-process assertions) and SlogSink (structured log line per event). Phase 2 replaces both with the SQLite-backed, hash-chained, append-only implementation (plus verify-chain and export) behind the same Sink interface, so callers written against this package need no changes.

Event.Details is free-form context (image reference, hostname, override code, …). It MUST NEVER contain secret values (golden rule 3): the audit log is durable, exported, and shown in the UI. SlogSink defensively redacts values whose key looks secret-bearing (see IsSensitiveKey), but that is a backstop, not a licence — callers put identifiers in Details, never values.

Index

Constants

View Source
const (
	ResultOK     = "ok"
	ResultDenied = "denied"
	ResultError  = "error"
)

Result values for Event.Result.

View Source
const (
	FormatJSONL = "jsonl"
	FormatCSV   = "csv"
)

Export formats.

View Source
const GenesisPrevHash = "0000000000000000000000000000000000000000000000000000000000000000"

GenesisPrevHash is the prev_hash of the first row of the chain: 64 zero hex digits, so that the genesis link is as visible in an export as every other one.

View Source
const PermAuditRead = "audit.read"

PermAuditRead mirrors auth.PermAuditRead. The auth package imports this one, so the string is duplicated here the same way internal/deploy mirrors its permissions; the httpapi tests pin the two constants to each other.

View Source
const Redacted = "[redacted]"

Redacted replaces the value of any Details entry whose key looks secret-bearing.

Variables

View Source
var CSVHeader = []string{"seq", "ts", "actor", "action", "resource_type", "resource_id", "request_id", "result", "details", "prev_hash", "hash"}

CSVHeader is the first line of a CSV export.

View Source
var ErrForbidden = errors.New("audit: forbidden")

ErrForbidden is returned when the actor lacks PermAuditRead (HTTP 403).

View Source
var ErrNoTimestamp = errors.New("audit: canonical: event has no timestamp")

ErrNoTimestamp is returned by Canonical for an event whose Time is zero: the timestamp is part of what is hashed, so it must be fixed before the bytes are.

View Source
var ErrUnknownFormat = errors.New("audit: unknown export format (want jsonl or csv)")

ErrUnknownFormat is returned for an export format other than jsonl or csv.

Functions

func Canonical

func Canonical(e Event) ([]byte, error)

Canonical returns the pinned, deterministic JSON encoding of e that the hash chain is computed over. The encoding is fixed forever (a change would break verification of every existing log):

  • one object, fields in this order and always present: ts, actor, action, resource_type, resource_id, request_id, result, details;
  • ts is RFC 3339 with nanoseconds (trailing zeros trimmed, as Go's RFC3339Nano does) in UTC, "Z" suffix;
  • details is an object whose keys are sorted byte-wise and whose values are strings — no number, boolean, null, array or nested object can ever appear;
  • strings are JSON-quoted without HTML escaping (<, >, & stay literal); control characters are \u00XX-escaped; invalid UTF-8 is replaced by U+FFFD before quoting, so the bytes are stable for any input;
  • no whitespace, no trailing newline.

Details values for keys that IsSensitiveKey considers secret-bearing are not encoded — they are dropped, exactly as Chain.Record drops them before storing (golden rule 3), so the hash covers what is stored and nothing else.

func CanonicalDetails

func CanonicalDetails(details map[string]string) string

CanonicalDetails encodes details alone (sorted keys, string values, sensitive keys dropped). This is the exact string stored in audit_events.details_json.

func FormatTime

func FormatTime(t time.Time) string

FormatTime renders t exactly as the canonical "ts" field (RFC 3339, nanoseconds, UTC).

func HashRow

func HashRow(canonical []byte, prevHash string) string

HashRow computes the chain hash of one row: hex(SHA-256(canonical || prev_hash)), where prev_hash is the previous row's hex hash as ASCII bytes (GenesisPrevHash for the first row).

func IsSensitiveKey

func IsSensitiveKey(key string) bool

IsSensitiveKey reports whether a Details key looks like it names a secret value: any token of the key (see keyTokens) is in sensitiveTokens and the last token is not an identifier word such as "name" or "id". It errs on the side of redacting — "db_password", "GITHUB_TOKEN", "clientSecret", "aws_secret_access_key", "deploy_key", "Authorization" and "totp_code" are all sensitive; "image", "tokenizer", "hostname", "secret_name" and "key_id" are not. Sinks that render values redact sensitive keys. This is a backstop for golden rule 3, not a licence to put secret values in Details.

func ParseFormat

func ParseFormat(s string) (string, error)

ParseFormat normalises a user-supplied format name.

Types

type Actor

type Actor struct {
	ID    string
	Email string
	Role  string
	// Can reports whether the actor holds a permission; nil means "bootstrap: allow all".
	Can func(perm string) bool
}

Actor identifies who reads the chain (from the session, API token or bootstrap principal). It mirrors deploy.Actor so the JSON API converts a principal the same way for every service.

type Chain

type Chain struct {
	Store *store.Store
	// Now is overridable for tests; nil means time.Now.
	Now func() time.Time
	// Redact, when set, is applied to every free-text field (actor, resource_id, details values)
	// before storage. platformd wires the process-wide secrets.Redactor so a secret value that a
	// caller mistakenly put in Details can never reach the durable log (golden rule 3 backstop).
	Redact func(string) string
	// contains filtered or unexported fields
}

Chain is the SQLite-backed, hash-chained, append-only audit log (golden rule 5). It implements Sink; every row's hash is hex(SHA-256(Canonical(event) || prev_hash)) with prev_hash the hex hash of the previous row (GenesisPrevHash for the first). UPDATE and DELETE on audit_events are refused by triggers (migration 00001), and Verify recomputes every hash and link so that an attacker who drops the triggers or edits the file directly is still detected.

Timestamps: an event whose Time is zero is stamped with Now() at insert time. A Time set by the caller is kept as-is — services stamp events with their own clock at the moment the state change happened, which is the instant an auditor cares about, and every caller is in-process (there is no remote submission path), so it is still a server-side timestamp.

func (*Chain) Export

func (c *Chain) Export(ctx context.Context, w io.Writer, format string) error

Export streams every row to w, oldest first. JSONL writes one object per line holding the event's canonical fields plus seq, prev_hash and hash (HTML is not escaped, so the line is what an auditor reads). CSV writes CSVHeader then one record per row with details as the canonical JSON object. Uploading the result to an S3 Object Lock bucket is a separate, optional step outside this package.

func (*Chain) ExportAs

func (c *Chain) ExportAs(ctx context.Context, actor Actor, w io.Writer, format string) error

ExportAs is Export for an actor: it requires PermAuditRead. Nothing is written to w when the actor is refused, so a handler can still answer 403.

func (*Chain) List

func (c *Chain) List(ctx context.Context, afterSeq int64, limit int64) ([]Row, error)

List returns up to limit rows with seq > afterSeq, oldest first. limit is clamped to 1..verifyPage (0 means 100).

func (*Chain) ListAs

func (c *Chain) ListAs(ctx context.Context, actor Actor, afterSeq, limit int64) ([]Row, error)

ListAs is List for an actor: it requires PermAuditRead (golden rule 6; the denial is audited).

func (*Chain) Record

func (c *Chain) Record(ctx context.Context, e Event) error

Record appends e to the chain. It never skips the write because ctx was cancelled (see Sink): the insert runs under context.WithoutCancel with its own bounded timeout. Details values under secret-bearing keys (IsSensitiveKey) are dropped, never stored; every stored string is passed through Redact when one is configured.

func (*Chain) Verify

func (c *Chain) Verify(ctx context.Context) (Report, error)

Verify walks every row in seq order, recomputing each hash and checking each prev_hash link and seq continuity. The first failure stops the walk and is reported; an empty chain is OK.

func (*Chain) VerifyAs

func (c *Chain) VerifyAs(ctx context.Context, actor Actor) (Report, error)

VerifyAs is Verify for an actor: it requires PermAuditRead.

type Event

type Event struct {
	Time         time.Time
	Actor        string
	Action       string
	ResourceType string
	ResourceID   string
	RequestID    string
	// Result is one of ResultOK, ResultDenied, ResultError.
	Result  string
	Details map[string]string
}

Event is one audit record. Details must never carry secret values (see the package comment).

func Stamp

func Stamp(e Event, now func() time.Time) Event

Stamp returns e with Time set to now() if it is zero. A nil now uses time.Now.

type MemorySink

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

MemorySink keeps events in memory, in the order they were recorded. It exists for tests and for the Phase 0 skeleton only; Phase 2 replaces it with the hash-chained SQLite log.

func NewMemorySink

func NewMemorySink() *MemorySink

NewMemorySink returns an empty, thread-safe in-memory sink.

func (*MemorySink) Events

func (m *MemorySink) Events() []Event

Events returns a copy of every recorded event, oldest first.

func (*MemorySink) Len

func (m *MemorySink) Len() int

Len returns the number of recorded events.

func (*MemorySink) Record

func (m *MemorySink) Record(_ context.Context, e Event) error

Record appends a copy of e. It performs no I/O, so ctx is accepted only to satisfy Sink; a cancelled ctx still records the event (see Sink).

type Report

type Report struct {
	// Rows is the number of rows examined.
	Rows int `json:"rows"`
	// OK is true when every row hashed and linked correctly and no seq was missing.
	OK bool `json:"ok"`
	// FirstBad is the seq of the first row that failed (or the first missing seq); nil when OK.
	FirstBad *int64 `json:"first_bad"`
	// Reason describes the first failure in one line; empty when OK.
	Reason string `json:"reason,omitempty"`
}

Report is the outcome of a chain verification.

func VerifyExport

func VerifyExport(r io.Reader) (Report, error)

VerifyExport re-verifies a JSONL export offline, without the database: every line is parsed strictly (unknown fields refused), its hash recomputed from the canonical encoding of its fields, and its prev_hash and seq checked against the previous line. This is what an auditor runs against a copy of the export they hold themselves. A malformed line is reported as the first bad row (its seq when it can be read, otherwise the seq expected at that position).

type Row

type Row struct {
	Seq          int64             `json:"seq"`
	TS           string            `json:"ts"`
	Actor        string            `json:"actor"`
	Action       string            `json:"action"`
	ResourceType string            `json:"resource_type"`
	ResourceID   string            `json:"resource_id"`
	RequestID    string            `json:"request_id"`
	Result       string            `json:"result"`
	Details      map[string]string `json:"details"`
	PrevHash     string            `json:"prev_hash"`
	Hash         string            `json:"hash"`
}

Row is one stored audit event with its position and hashes.

func (Row) Event

func (r Row) Event() Event

Event converts the row back into an Event (Time parsed from TS; zero if unparseable).

type Sink

type Sink interface {
	Record(ctx context.Context, e Event) error
}

Sink receives audit events. Implementations must be safe for concurrent use.

Record must never drop an event because the request that triggered the action was cancelled: by the time Record is called the state change has already happened, and golden rule 5 requires that it be recorded regardless of whether the client is still connected. ctx therefore carries request-scoped values (trace/request IDs for the logger) but not a licence to skip the write. I/O-backed sinks (the Phase 2 SQLite hash chain) must perform the insert under context.WithoutCancel(ctx), applying their own bounded timeout if one is needed.

func Multi

func Multi(sinks ...Sink) Sink

Multi returns a Sink that records to every sink in order and joins their errors. Every sink is attempted even if an earlier one fails. Nil sinks are skipped.

type SlogSink

type SlogSink struct {
	Logger *slog.Logger
}

SlogSink writes one Info-level structured log line per event. Values of sensitive-looking Details keys are replaced with Redacted; nothing else about the event is transformed.

func (SlogSink) Record

func (s SlogSink) Record(ctx context.Context, e Event) error

Record logs the event. A nil Logger falls back to slog.Default. ctx is passed to the handler (for trace/request attributes) but a cancelled ctx still logs the event (see Sink).

Jump to

Keyboard shortcuts

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