audit

package
v1.801.441 Latest Latest
Warning

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

Go to latest
Published: Aug 4, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

Documentation

Overview

Package audit is the unified cloud binary's compliance-grade audit trail — tamper-evident, append-only, and complete over the security-relevant request surface (FedRAMP AU-* / SOC 2 CC-* controls).

THE CONTROL, IN ONE SENTENCE. Every security-relevant action against this binary is captured as a structured Record, hash-chained to its predecessor so any later deletion or modification is detectable, and written INLINE (never dropped) to an append-only store the application can only INSERT into.

THREE PIECES, EACH IN ITS LANE (orthogonal, per the Zen of Hanzo):

  • record.go — the event model + the hash-chain math (what a record IS and how it links to the one before it). Pure, no I/O.
  • store.go — the append-only sink (SQLite primary, INSERT-only; a best-effort OLAP mirror) and the serialized Recorder that owns the chain head. All persistence.
  • redact.go — the secret-stripping allowlist/denylist for any structured before/after an explicit emit point supplies. No secret ever reaches a record.

The HTTP middleware (Middleware, in the cloud package) and the query/verify endpoints (in clients/admin) are thin callers of this package. This package holds the security logic; it has zero knowledge of routes.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Redact

func Redact(raw json.RawMessage) json.RawMessage

Redact returns a copy of the JSON value with every secret-keyed value replaced by the redaction marker, recursively through objects and arrays. Non-JSON or empty input yields nil (nothing to record). On a JSON parse error the input is dropped (returns the marker as a JSON string) rather than passed through — fail closed.

Use it at any explicit emit point that supplies before/after:

audit.Emit(ctx, rec.WithChange(audit.Redact(before), audit.Redact(after)))

Types

type Actor

type Actor struct {
	// Org is the tenant the action was taken IN — the EFFECTIVE org (X-Org-Id).
	// For all but one caller this is also the actor's own org. Empty for an
	// unauthenticated request.
	Org string `json:"org"`
	// Sub is the user id (IAM `sub`/`preferred_username`). Empty for a service
	// principal or an anonymous request.
	Sub string `json:"sub"`
	// Email is the validated user email, when present.
	Email string `json:"email,omitempty"`

	// Home is the actor's OWN org (the validated home-org claim, X-User-Owner),
	// recorded ONLY when it DIFFERS from Org — i.e. ONLY when this action was
	// taken by a platform SuperAdmin acting INSIDE ANOTHER TENANT.
	//
	// Recording it conditionally is what makes the field MEAN something: a
	// non-empty Home is, by construction, an impersonation event. Without it an
	// admin org-switch is indistinguishable from a native member of the target
	// org, because Org alone says "lux" in both cases — the impersonation fact
	// was being destroyed at the moment of recording.
	//
	// HASH COMPATIBILITY (why omitempty is load-bearing, not style): the chain
	// hashes the canonical JSON of the record (canonicalBytes). An omitempty
	// field that is empty marshals to NOTHING, so every pre-existing record —
	// and every ordinary same-org record written from now on — produces byte-
	// identical canonical JSON to before this field existed. Existing hashes
	// still verify. See TestVerify_HomeFieldIsHashCompatible.
	Home string `json:"home,omitempty"`
}

Actor identifies WHO performed the action. It is populated ONLY from a validated principal (the sanitized X-User-* headers SanitizeIdentity mints from a verified IAM JWT), never from a raw client header — so an actor can never be forged by the request that is being audited. A service principal (M2M / no user sub) records Org with an empty Sub.

type AuthContext

type AuthContext struct {
	Method  string `json:"method"`
	IsAdmin bool   `json:"isAdmin"`
}

AuthContext records HOW the actor authenticated and what authority they held at decision time — the AC-* evidence (was this a SuperAdmin? by what credential?). Method is "jwt" | "api-key" | "none". IsAdmin is the VALIDATED SuperAdmin bit (owner == AdminOrg), never a raw X-User-IsAdmin.

type Checkpoint

type Checkpoint struct {
	Time  time.Time `json:"time"`
	Count uint64    `json:"count"`
	Head  string    `json:"head"`
}

Checkpoint is a periodic, tamper-EVIDENCE digest of the chain head: the record count and the head hash at a moment in time. It is the AU-9 anchor for TAIL-TRUNCATION detection — an internal chain walk cannot notice that the last K records were deleted (the surviving prefix still verifies), but a durable, INDEPENDENT series of head checkpoints can: Count is monotonic, so any decrease between two consecutive checkpoints is deletion, and an attacker cannot forge a higher count without appending records whose hashes the chain walk would reject.

type CheckpointFunc

type CheckpointFunc func(cp Checkpoint)

CheckpointFunc receives a head digest for the structured (o11y) log. It is a plain func so the pure audit package stays free of any concrete logger type; the cloud wiring adapts luxlog to it.

type CheckpointSink

type CheckpointSink interface {
	Checkpoint(ctx context.Context, cp Checkpoint) error
}

CheckpointSink is an optional capability a Mirror may implement to persist the head digest series to an INDEPENDENT store (so truncating the local SQLite cannot also rewrite the anchor history). A Mirror that does not implement it still gets its records; checkpoints then flow only to the structured log.

type Filter

type Filter struct {
	Org  string // actor_org exact match (the org acted IN)
	Sub  string // actor_sub exact match (a specific user)
	Home string // actor_home exact match — the org the actor came FROM.
	// Impersonated restricts to CROSS-ORG actions only (actor_home <> ”), which
	// is the question this control exists to answer: "show me every time a
	// platform admin acted inside a tenant that was not their own." Without it an
	// auditor would have to scan the whole trail to find the events that matter
	// most.
	Impersonated bool
	Action       string    // action exact match
	Resource     string    // res_type exact match
	ResourceID   string    // res_id exact match (a specific resource instance)
	Result       string    // outcome result: success|deny|error
	Since        time.Time // ts >= Since (UTC)
	Until        time.Time // ts <= Until (UTC)
	Limit        int       // max rows (default 100, cap 1000)
	Offset       int       // pagination offset
}

Filter narrows a Query. Zero-value fields are ignored (no constraint), so an empty Filter returns the most-recent Limit records. Time bounds are inclusive and compared against the RFC3339Nano ts column lexicographically (RFC3339 is order-preserving as text, so a string range is a correct time range).

type Integrity

type Integrity struct {
	// OK is true iff every record's stored hash equals the recomputed hash AND the
	// chain links are continuous (each PrevHash == the prior record's Hash, seqs
	// gapless from 0).
	OK bool `json:"ok"`
	// Count is the number of records walked.
	Count uint64 `json:"count"`
	// HeadHash is the hash of the last record (or the genesis anchor for an empty
	// chain). Pin this externally over time to detect tail-truncation.
	HeadHash string `json:"headHash"`
	// BrokenAt is the seq of the FIRST record that failed verification, or -1 when
	// OK. Reason describes the break (recomputed-hash mismatch, prev-hash
	// discontinuity, or a seq gap).
	BrokenAt int64  `json:"brokenAt"`
	Reason   string `json:"reason,omitempty"`
}

Integrity is the result of a Verify walk — the AU-9 evidence that the trail has not been tampered with.

type Mirror

type Mirror interface {
	// Append writes one sealed record to the projection. A returned error is
	// logged and dropped by the Recorder — the mirror never gates a request.
	Append(ctx context.Context, r Record) error
}

Mirror is the optional OLAP projection sink (the datastore/datastore). It is deliberately a tiny interface, not a concrete client, so the Recorder has no compile-time dependency on datastore and tests can supply a fake. Append is called best-effort, asynchronously, off the request path.

type Outcome

type Outcome struct {
	Result string `json:"result"`
	Status int    `json:"status"`
	Reason string `json:"reason,omitempty"`
}

Outcome is the result of the action: whether it was allowed and what happened. Result is "success" | "deny" | "error". Status is the HTTP status. Reason is a short, non-sensitive explanation for a deny/error (e.g. "SuperAdmin required", "insufficient_balance") — never a secret, never a raw upstream error body.

type Record

type Record struct {
	// Seq is the strictly-increasing chain position (0-based). It is assigned by
	// the Recorder under its lock, so it is a true total order with no gaps.
	Seq uint64 `json:"seq"`

	// Time is the UTC event timestamp (RFC3339Nano).
	Time time.Time `json:"time"`

	// Actor / Action / Resource / Auth / Outcome — the AU-3 "content of audit
	// records" core: who, what, on what, how-authenticated, with what result.
	Actor    Actor       `json:"actor"`
	Action   string      `json:"action"`
	Resource Resource    `json:"resource"`
	Auth     AuthContext `json:"auth"`
	Outcome  Outcome     `json:"outcome"`

	// SourceIP + UserAgent — the AU-3 "source of the event" fields.
	SourceIP  string `json:"sourceIp,omitempty"`
	UserAgent string `json:"userAgent,omitempty"`

	// RequestID correlates the record to the request-line log and any downstream
	// trace (the X-Request-Id the pipeline mints).
	RequestID string `json:"requestId,omitempty"`

	// Method + Path are the HTTP verb and route for a request-sourced event.
	Method string `json:"method,omitempty"`
	Path   string `json:"path,omitempty"`

	// Before / After capture a mutation's prior and resulting state for the
	// AU-required "before/after" on config-affecting changes. They are populated
	// ONLY by explicit emit points and ONLY after Redact has stripped secrets —
	// the HTTP middleware never sets them (it never reads bodies), so a secret in
	// a request body can never leak here. Raw JSON so any shape round-trips.
	Before json.RawMessage `json:"before,omitempty"`
	After  json.RawMessage `json:"after,omitempty"`

	// PrevHash is the hash of record Seq-1 (hex). For the genesis record (Seq 0)
	// it is genesisPrevHash. Hash is this record's hash. Neither participates in
	// its own hash computation (both are zeroed in canonicalBytes).
	PrevHash string `json:"prevHash"`
	Hash     string `json:"hash"`
}

Record is one audit event. The JSON tags ARE the on-disk and on-wire contract.

Field order in the struct is deliberate but IRRELEVANT to the hash: the chain hashes the CANONICAL (sorted-key) JSON of the record with Hash/PrevHash zeroed (see canonicalBytes), so re-ordering fields or adding an omitempty field can never change an existing record's hash.

func (Record) ToWire added in v1.786.131

func (r Record) ToWire() Wire

ToWire projects a stored Record onto the console wire shape. Time is emitted as RFC3339Nano (UTC) — the same nanosecond-precise, order-preserving format the ts column stores — so a client can sort/range on it verbatim.

type Recorder

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

Recorder is the single serialized writer that owns the audit chain head and the append-only store. Every Record flows through Append, which under one lock assigns the next Seq, links PrevHash to the current head, seals (hashes), and synchronously persists to SQLite before returning. Concurrency is serialized by mu AND by the single-connection SQLite pool, so the on-disk order equals the chain order with no gaps.

func Open

func Open(dir, subsystem string, mirror Mirror) (*Recorder, error)

Open opens (creating if needed) the append-only audit chain named by subsystem under dir, and recovers the chain head from it, so a restart continues the SAME chain rather than forking a new one. mirror may be nil.

The trail belongs to the DEPLOYMENT and not to any tenant, so it is keyed under the system namespace. Which chain — one process's — is the subsystem.

MaxOpenConns(1) serializes every statement against the file lock — the same single-writer discipline pricing/provisioning use, here doubling as the chain's serialization guarantee.

func (*Recorder) Append

func (r *Recorder) Append(ctx context.Context, rec Record) (Record, error)

Append seals r into the next chain position and persists it. It fills Seq, PrevHash, and Hash (the caller sets everything else), advances the in-memory head only AFTER the durable INSERT succeeds, and mirrors best-effort. A persistence error is returned so the caller can fail the request CLOSED — the head is NOT advanced on failure, so the chain never gaps.

The whole critical section (assign seq → seal → INSERT → advance head) holds mu, so two concurrent requests can never claim the same seq or race the head.

func (*Recorder) Close

func (r *Recorder) Close() error

Close stops the periodic checkpoint emitter, emits a FINAL checkpoint SYNCHRONOUSLY (so the head at shutdown reaches both the o11y log and the independent digest store before the process exits — the AU-9 anchor must be current exactly when an attacker might trigger shutdown then truncate), and closes the underlying database.

func (*Recorder) Head

func (r *Recorder) Head() (count uint64, headHash string)

Head returns the current chain head (count of records, and the head hash). A count of 0 means the genesis (empty) chain, headHash == genesisPrevHash. An external monitor can pin (count, headHash) over time to detect tail-truncation — which an internal chain walk alone cannot catch (a truncated prefix still verifies). This is the anchor point for AU-9 protection against deletion of the most-recent records.

func (*Recorder) Query

func (r *Recorder) Query(ctx context.Context, f Filter) (rows []Record, total int, err error)

Query returns records matching f, newest first, and the total count matching the same predicate (ignoring Limit/Offset) for pagination. All predicates are parameterized — never string-interpolated — so a filter value can never inject SQL. Column names in the WHERE come from a fixed allowlist below, not caller input.

func (*Recorder) StartCheckpoints

func (r *Recorder) StartCheckpoints(every time.Duration, logFn CheckpointFunc)

StartCheckpoints begins periodic head-digest emission every `every` (no ticker if every<=0; the on-Close checkpoint still fires). logFn, when non-nil, receives each digest for the append-only observability log (o11y), and a mirror implementing CheckpointSink also gets it persisted to an INDEPENDENT store — together the AU-9 anchor an external monitor compares to detect tail-truncation (count regression). MUST be called at most once, before any concurrent Append (a second call is ignored); the emitter stops on Close.

func (*Recorder) Verify

func (r *Recorder) Verify(ctx context.Context) (Integrity, error)

Verify walks the entire chain in seq order, recomputing each record's hash from its content + the running prev-hash and checking continuity. It is the tamper-detector: any modification (a changed field re-hashes differently), any deletion or reordering (a seq gap or a broken prev-hash link), or a forged row (its recomputed hash won't match unless the attacker also recomputed the entire suffix — which they cannot do without re-inserting every subsequent record) is reported with the exact seq where the chain first breaks.

Complexity is O(n) over the records; for very large trails this streams row by row (no full materialization). At cloud's audit volume this is fine; if a trail grows past what an on-demand full walk should touch, verify a seq WINDOW (Verify is easily extended with a bound) or rely on the externally-pinned head.

type Resource

type Resource struct {
	Type string `json:"type"`
	ID   string `json:"id,omitempty"`
}

Resource identifies WHAT was acted upon: a type (e.g. "org", "role", "secret", "provider-config", "credit") and its id. For a plain HTTP mutation with no finer resource semantics, Type is the route family and ID is empty — the Action verb + path already pin the object.

type Wire added in v1.786.131

type Wire struct {
	Seq   uint64 `json:"seq"`
	Time  string `json:"time"`
	Org   string `json:"org"`
	Sub   string `json:"sub"`
	Email string `json:"email,omitempty"`
	// Home is present ONLY on a cross-org action: the org the actor came FROM,
	// while Org is the org they acted IN. A console row carrying `home` is a
	// platform-admin impersonation and should be rendered as one.
	Home       string `json:"home,omitempty"`
	Action     string `json:"action"`
	Resource   string `json:"resource"`
	ResourceID string `json:"resourceId,omitempty"`
	Method     string `json:"method,omitempty"`
	Path       string `json:"path,omitempty"`
	Result     string `json:"result"`
	Status     int    `json:"status"`
	Reason     string `json:"reason,omitempty"`
	SourceIP   string `json:"sourceIp,omitempty"`
	UserAgent  string `json:"userAgent,omitempty"`
	RequestID  string `json:"requestId,omitempty"`
	IsAdmin    bool   `json:"isAdmin"`
	Auth       string `json:"authMethod,omitempty"`
	Hash       string `json:"hash"`
	PrevHash   string `json:"prevHash"`
}

Wire is the JSON shape of one audit record on the operator/console contract. It is cloud's OWN record projection — richer than the IAM record it supersedes: it carries the outcome, the validated auth context, and the hash-chain linkage (Hash/PrevHash) so a console can show tamper-evidence per row. The JSON tags ARE the contract; both the admin god-view (/v1/admin/audit) and the org-scoped trail (/v1/audit) serialize this ONE shape so a single console adapter reads either.

Jump to

Keyboard shortcuts

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