diff --git a/docs/docs/sidebar/features/audit-logging.md b/docs/docs/sidebar/features/audit-logging.md
index d5ba0f7bf..981a0b666 100644
--- a/docs/docs/sidebar/features/audit-logging.md
+++ b/docs/docs/sidebar/features/audit-logging.md
@@ -12,37 +12,38 @@ accountability and supports compliance requirements.
The API server records audit entries automatically via middleware. Every
authenticated request generates an audit entry that is stored in a dedicated
-NATS KV bucket. No application code needs to explicitly log audit events -- the
-middleware handles it transparently.
+NATS JetStream stream. No application code needs to explicitly log audit events
+-- the middleware handles it transparently.
```mermaid
sequenceDiagram
participant Client
participant Middleware as Audit Middleware
participant Handler
- participant KV as NATS KV
+ participant Stream as NATS Stream
Client->>Middleware: API request
Middleware->>Handler: forward request
Handler-->>Middleware: response
- Middleware->>KV: write audit entry
+ Middleware->>Stream: write audit entry
Middleware-->>Client: response
```
Each audit entry contains:
-| Field | Description |
-| --------------- | --------------------------------------- |
-| `id` | Unique entry identifier (UUID) |
-| `timestamp` | When the request was made |
-| `user` | Identity from the JWT `sub` claim |
-| `roles` | Roles from the JWT token |
-| `method` | HTTP method (`GET`, `POST`, etc.) |
-| `path` | Request path (e.g., `/node/hostname`) |
-| `operation_id` | OpenAPI operation ID (if available) |
-| `source_ip` | Client IP address |
-| `response_code` | HTTP response status code |
-| `duration_ms` | Request processing time in milliseconds |
+| Field | Description |
+| --------------- | ------------------------------------------------ |
+| `id` | Unique entry identifier (UUID) |
+| `timestamp` | When the request was made |
+| `user` | Identity from the JWT `sub` claim |
+| `roles` | Roles from the JWT token |
+| `method` | HTTP method (`GET`, `POST`, etc.) |
+| `path` | Request path (e.g., `/node/hostname`) |
+| `operation_id` | OpenAPI operation ID (if available) |
+| `source_ip` | Client IP address |
+| `response_code` | HTTP response status code |
+| `duration_ms` | Request processing time in milliseconds |
+| `trace_id` | OpenTelemetry trace ID (when tracing is enabled) |
## Viewing Audit Logs
@@ -55,8 +56,8 @@ the [API Reference](/gen/api/audit-log-api-audit) for the REST endpoints.
The export feature retrieves all audit entries and writes them to a local file
in JSONL format (one JSON object per line). This is designed for long-term
-retention -- since audit entries in NATS KV have a configurable TTL (default 30
-days), exporting preserves them before they expire.
+retention -- since audit entries in the NATS stream have a configurable
+`max_age` (default 30 days), exporting preserves them before they expire.
The `GET /audit/export` endpoint returns all entries in a single response. The
CLI writes each entry as a JSON line to the output file. JSONL files are easy to
@@ -75,23 +76,27 @@ cat audit.jsonl | jq .
## Retention
-Audit entries are stored in a NATS KV bucket with configurable retention
-settings. When entries exceed the TTL or the bucket reaches its size limit,
-older entries are automatically removed. Export entries before they expire if
-you need long-term retention.
+Audit entries are stored in a NATS JetStream stream with configurable retention
+settings. When entries exceed the `max_age` or the stream reaches its size
+limit, older entries are automatically removed. Export entries before they
+expire if you need long-term retention.
## Configuration
```yaml
nats:
audit:
- bucket: 'audit-log' # KV bucket name
- ttl: '720h' # 30-day retention (default)
- max_bytes: 52428800 # 50 MiB max bucket size
+ stream: 'AUDIT' # JetStream stream name
+ subject: 'audit' # Base subject prefix
+ max_age: '720h' # 30-day retention (default)
+ max_bytes: 52428800 # 50 MiB max stream size
storage: 'file' # "file" or "memory"
- replicas: 1 # Number of KV replicas
+ replicas: 1 # Number of stream replicas
```
+Each audit entry includes a `trace_id` field when distributed tracing is
+enabled, allowing correlation with OpenTelemetry traces.
+
See [Configuration](../usage/configuration.md#natsaudit) for the full reference.
## Permissions
diff --git a/docs/docs/sidebar/usage/cli/client/audit/get.md b/docs/docs/sidebar/usage/cli/client/audit/get.md
index ab6f80fe8..2665eab33 100644
--- a/docs/docs/sidebar/usage/cli/client/audit/get.md
+++ b/docs/docs/sidebar/usage/cli/client/audit/get.md
@@ -13,6 +13,7 @@ $ osapi client audit get --audit-id 550e8400-e29b-41d4-a716-446655440000
Method: GET Path: /node/hostname
Status: 200 Duration: 42ms
Source IP: 127.0.0.1
+ Trace ID: 4bf92f3577b34da6a3ce929d0e0e4736
```
## Flags
@@ -25,5 +26,5 @@ Use `--json` for raw JSON output:
```bash
$ osapi client audit get --audit-id 550e8400-e29b-41d4-a716-446655440000 --json
-{"entry":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2026-02-21T10:30:00Z","user":"ops@example.com","roles":["admin"],"method":"GET","path":"/node/hostname","source_ip":"127.0.0.1","response_code":200,"duration_ms":42}}
+{"entry":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2026-02-21T10:30:00Z","user":"ops@example.com","roles":["admin"],"method":"GET","path":"/node/hostname","source_ip":"127.0.0.1","response_code":200,"duration_ms":42,"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}}
```
diff --git a/docs/docs/sidebar/usage/cli/client/audit/list.md b/docs/docs/sidebar/usage/cli/client/audit/list.md
index 78170a35a..962536344 100644
--- a/docs/docs/sidebar/usage/cli/client/audit/list.md
+++ b/docs/docs/sidebar/usage/cli/client/audit/list.md
@@ -11,10 +11,10 @@ $ osapi client audit list
Audit Entries:
- ID TIMESTAMP USER METHOD PATH STATUS DURATION
- 550e…000 2026-02-21 10:30:00 ops@example.com GET /node/hostname 200 42ms
- 661f…111 2026-02-21 10:29:55 ops@example.com POST /job 201 15ms
- 772a…222 2026-02-21 10:29:50 ops@example.com GET /node/_any/network/dns/eth0 200 8ms
+ ID TIMESTAMP USER METHOD PATH STATUS DURATION TRACE ID
+ 550e…000 2026-02-21 10:30:00 ops@example.com GET /node/hostname 200 42ms 4bf92f3577b34da6…
+ 661f…111 2026-02-21 10:29:55 ops@example.com POST /job 201 15ms a1b2c3d4e5f67890…
+ 772a…222 2026-02-21 10:29:50 ops@example.com GET /node/_any/network/dns/eth0 200 8ms f0e1d2c3b4a59687…
```
Use `--limit` and `--offset` for pagination:
diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md
index aceead234..5697c6cf1 100644
--- a/docs/docs/sidebar/usage/configuration.md
+++ b/docs/docs/sidebar/usage/configuration.md
@@ -45,8 +45,9 @@ uppercased:
| `nats.stream.name` | `OSAPI_NATS_STREAM_NAME` |
| `nats.kv.bucket` | `OSAPI_NATS_KV_BUCKET` |
| `nats.kv.response_bucket` | `OSAPI_NATS_KV_RESPONSE_BUCKET` |
-| `nats.audit.bucket` | `OSAPI_NATS_AUDIT_BUCKET` |
-| `nats.audit.ttl` | `OSAPI_NATS_AUDIT_TTL` |
+| `nats.audit.stream` | `OSAPI_NATS_AUDIT_STREAM` |
+| `nats.audit.subject` | `OSAPI_NATS_AUDIT_SUBJECT` |
+| `nats.audit.max_age` | `OSAPI_NATS_AUDIT_MAX_AGE` |
| `nats.audit.max_bytes` | `OSAPI_NATS_AUDIT_MAX_BYTES` |
| `nats.audit.storage` | `OSAPI_NATS_AUDIT_STORAGE` |
| `nats.audit.replicas` | `OSAPI_NATS_AUDIT_REPLICAS` |
@@ -345,17 +346,19 @@ nats:
# Number of KV replicas.
replicas: 1
- # ── Audit log KV bucket ──────────────────────────────────
+ # ── Audit log stream ──────────────────────────────────────
audit:
- # KV bucket for audit log entries.
- bucket: 'audit-log'
- # TTL for audit entries (Go duration). Default 30 days.
- ttl: '720h'
- # Maximum total size of the audit bucket in bytes.
+ # JetStream stream name for audit log entries.
+ stream: 'AUDIT'
+ # Base subject prefix for audit messages.
+ subject: 'audit'
+ # Maximum age of audit entries (Go duration). Default 30 days.
+ max_age: '720h'
+ # Maximum total size of the audit stream in bytes.
max_bytes: 52428800 # 50 MiB
# Storage backend: "file" or "memory".
storage: 'file'
- # Number of KV replicas.
+ # Number of stream replicas.
replicas: 1
# ── Agent registry KV bucket ──────────────────────────────
@@ -585,13 +588,14 @@ When enabled, the port also serves `/health` (liveness) and `/health/ready`
### `nats.audit`
-| Key | Type | Description |
-| ----------- | ------ | -------------------------------- |
-| `bucket` | string | KV bucket for audit log entries |
-| `ttl` | string | Entry time-to-live (Go duration) |
-| `max_bytes` | int | Maximum bucket size in bytes |
-| `storage` | string | `"file"` or `"memory"` |
-| `replicas` | int | Number of KV replicas |
+| Key | Type | Description |
+| ----------- | ------ | ------------------------------------------- |
+| `stream` | string | JetStream stream name for audit log entries |
+| `subject` | string | Base subject prefix for audit messages |
+| `max_age` | string | Maximum entry age (Go duration) |
+| `max_bytes` | int | Maximum stream size in bytes |
+| `storage` | string | `"file"` or `"memory"` |
+| `replicas` | int | Number of stream replicas |
### `nats.registry`
diff --git a/docs/plans/2026-04-01-audit-stream-migration-plan.md b/docs/plans/2026-04-01-audit-stream-migration-plan.md
new file mode 100644
index 000000000..1c7b15261
--- /dev/null
+++ b/docs/plans/2026-04-01-audit-stream-migration-plan.md
@@ -0,0 +1,807 @@
+# Audit Stream Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use
+> superpowers:subagent-driven-development (recommended) or
+> superpowers:executing-plans to implement this plan task-by-task. Steps use
+> checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the audit KV store with a JetStream stream for chronological
+ordering and efficient pagination, add `trace_id` to audit entries.
+
+**Architecture:** The audit `StreamStore` receives a `jetstream.Stream` handle
+(for reads: `GetLastMsgForSubject`, `OrderedConsumer`, `Info`) and uses
+`nc.Publish()` (via a `Publisher` interface) for writes. The `Store` interface
+is unchanged — all consumers (handlers, middleware, export) work without
+modification. Config changes from KV bucket fields to stream fields.
+
+**Tech Stack:** Go, NATS JetStream streams, OpenTelemetry trace context
+
+---
+
+### Task 1: Update config types and YAML
+
+**Files:**
+
+- Modify: `internal/config/types.go:124-132`
+- Modify: `configs/osapi.yaml` (default config)
+- Modify: `configs/osapi.nerd.yaml` (dev config)
+
+- [ ] **Step 1: Update the NATSAudit config struct**
+
+Replace the KV bucket config with stream config:
+
+```go
+// NATSAudit configuration for the audit log stream.
+type NATSAudit struct {
+ // Stream is the JetStream stream name for audit log entries.
+ Stream string `mapstructure:"stream"`
+ // Subject is the base subject prefix for audit messages.
+ Subject string `mapstructure:"subject"`
+ MaxAge string `mapstructure:"max_age"` // e.g. "720h" (30 days)
+ MaxBytes int64 `mapstructure:"max_bytes"`
+ Storage string `mapstructure:"storage"` // "file" or "memory"
+ Replicas int `mapstructure:"replicas"`
+}
+```
+
+- [ ] **Step 2: Update osapi.yaml configs**
+
+In both `configs/osapi.yaml` and `configs/osapi.nerd.yaml`, change the
+`nats.audit` section:
+
+```yaml
+nats:
+ audit:
+ stream: 'AUDIT'
+ subject: 'audit'
+ max_age: '720h'
+ max_bytes: 52428800
+ storage: 'file'
+ replicas: 1
+```
+
+- [ ] **Step 3: Verify it compiles**
+
+Run: `go build ./...`
+
+Expect: compile errors in files that reference `NATSAudit.Bucket` and
+`NATSAudit.TTL` — that's expected, we fix them in subsequent tasks.
+
+- [ ] **Step 4: Commit**
+
+```
+chore(config): rename audit config from KV bucket to stream
+```
+
+---
+
+### Task 2: Update CLI config builder and NATS setup
+
+**Files:**
+
+- Modify: `internal/cli/nats.go:128-142`
+- Modify: `internal/cli/nats_public_test.go` (update test for renamed function)
+- Modify: `cmd/nats_setup.go:150-155`
+
+- [ ] **Step 1: Replace BuildAuditKVConfig with BuildAuditStreamConfig**
+
+In `internal/cli/nats.go`, replace `BuildAuditKVConfig`:
+
+```go
+// BuildAuditStreamConfig builds a jetstream.StreamConfig from audit
+// config values.
+func BuildAuditStreamConfig(
+ namespace string,
+ auditCfg config.NATSAudit,
+) jetstream.StreamConfig {
+ streamName := job.ApplyNamespaceToInfraName(
+ namespace,
+ auditCfg.Stream,
+ )
+ subject := job.ApplyNamespaceToSubjects(
+ namespace,
+ auditCfg.Subject,
+ )
+ maxAge, _ := time.ParseDuration(auditCfg.MaxAge)
+
+ return jetstream.StreamConfig{
+ Name: streamName,
+ Subjects: []string{subject + ".>"},
+ MaxAge: maxAge,
+ MaxBytes: auditCfg.MaxBytes,
+ Storage: ParseJetstreamStorageType(auditCfg.Storage),
+ Replicas: auditCfg.Replicas,
+ Discard: jetstream.DiscardOld,
+ }
+}
+```
+
+- [ ] **Step 2: Update the test for the renamed function**
+
+In `internal/cli/nats_public_test.go`, update the test that exercises
+`BuildAuditKVConfig` to test `BuildAuditStreamConfig` instead. The test should
+verify stream name, subjects with `.>` suffix, max age, storage type, and
+replicas.
+
+- [ ] **Step 3: Update nats_setup.go to create stream instead of KV**
+
+In `cmd/nats_setup.go`, replace the audit KV bucket creation block:
+
+```go
+if appConfig.NATS.Audit.Stream != "" {
+ auditStreamConfig := cli.BuildAuditStreamConfig(
+ namespace,
+ appConfig.NATS.Audit,
+ )
+ if err := nc.CreateOrUpdateStreamWithConfig(
+ ctx,
+ auditStreamConfig,
+ ); err != nil {
+ return fmt.Errorf(
+ "create audit stream %s: %w",
+ auditStreamConfig.Name,
+ err,
+ )
+ }
+}
+```
+
+- [ ] **Step 4: Run tests and verify build**
+
+Run: `go test ./internal/cli/... -count=1` Run: `go build ./...`
+
+Expect: cli tests pass, build still has errors in controller_setup.go (expected
+— fixed in Task 4).
+
+- [ ] **Step 5: Commit**
+
+```
+feat(audit): replace KV bucket setup with stream creation
+```
+
+---
+
+### Task 3: Add TraceID to audit entry and OpenAPI spec
+
+**Files:**
+
+- Modify: `internal/audit/types.go:27-48`
+- Modify: `internal/controller/api/audit/gen/api.yaml:190-247`
+- Modify: `internal/controller/api/audit/audit_list.go:75-94` (mapEntryToGen)
+- Modify: `internal/controller/api/middleware_audit.go`
+- Modify: `internal/controller/api/export_test.go` (if needed)
+- Modify: `pkg/sdk/client/audit_types.go`
+
+- [ ] **Step 1: Add TraceID to the audit Entry struct**
+
+In `internal/audit/types.go`, add the field:
+
+```go
+type Entry struct {
+ ID string `json:"id"`
+ Timestamp time.Time `json:"timestamp"`
+ User string `json:"user"`
+ Roles []string `json:"roles"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ OperationID string `json:"operation_id,omitempty"`
+ SourceIP string `json:"source_ip"`
+ ResponseCode int `json:"response_code"`
+ DurationMs int64 `json:"duration_ms"`
+ TraceID string `json:"trace_id,omitempty"`
+}
+```
+
+- [ ] **Step 2: Add trace_id to the OpenAPI spec**
+
+In `internal/controller/api/audit/gen/api.yaml`, add `trace_id` to the
+`AuditEntry` schema properties (after `duration_ms`):
+
+```yaml
+trace_id:
+ type: string
+ description: OpenTelemetry trace ID for correlation.
+ example: '4bf92f3577b34da6a3ce929d0e0e4736'
+```
+
+Do NOT add it to `required` — it's optional (empty when tracing is disabled).
+
+- [ ] **Step 3: Regenerate OpenAPI code**
+
+Run: `just generate`
+
+- [ ] **Step 4: Update mapEntryToGen in audit_list.go**
+
+Add the `TraceID` mapping in `mapEntryToGen`:
+
+```go
+if e.TraceID != "" {
+ entry.TraceId = &e.TraceID
+}
+```
+
+- [ ] **Step 5: Add trace ID extraction to the audit middleware**
+
+In `internal/controller/api/middleware_audit.go`, add the import for
+`go.opentelemetry.io/otel/trace` and extract the trace ID:
+
+```go
+spanCtx := trace.SpanContextFromContext(
+ c.Request().Context(),
+)
+if spanCtx.HasTraceID() {
+ entry.TraceID = spanCtx.TraceID().String()
+}
+```
+
+Add this after building the `entry` struct and before the goroutine that writes
+it.
+
+- [ ] **Step 6: Add TraceID to SDK audit types**
+
+In `pkg/sdk/client/audit_types.go`, add to `AuditEntry`:
+
+```go
+TraceID string `json:"trace_id,omitempty"`
+```
+
+Update `auditEntryFromGen` to map the field:
+
+```go
+if g.TraceId != nil {
+ a.TraceID = *g.TraceId
+}
+```
+
+- [ ] **Step 7: Run tests**
+
+Run: `go test ./internal/controller/api/audit/... -count=1` Run:
+`go test ./internal/controller/api/ -run Audit -count=1` Run:
+`go test ./pkg/sdk/client/ -run Audit -count=1`
+
+Expect: all pass. The middleware test uses a hand-written spy that already
+accepts the new field (it stores the full `Entry`). The trace ID will be empty
+in tests since there's no OTel span — that's correct.
+
+- [ ] **Step 8: Commit**
+
+```
+feat(audit): add trace_id field for OpenTelemetry correlation
+```
+
+---
+
+### Task 4: Implement the stream store
+
+**Files:**
+
+- Create: `internal/audit/stream_store.go`
+- Create: `internal/audit/stream_store_public_test.go`
+- Modify: `internal/audit/export_test.go` (keep marshalJSON export)
+- Delete: `internal/audit/kv_store.go`
+- Delete: `internal/audit/kv_store_test.go`
+- Delete: `internal/audit/kv_store_public_test.go`
+
+- [ ] **Step 1: Create the StreamStore**
+
+Create `internal/audit/stream_store.go`:
+
+```go
+package audit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+
+ "github.com/nats-io/nats.go/jetstream"
+)
+
+// ensure StreamStore implements Store at compile time.
+var _ Store = (*StreamStore)(nil)
+
+// marshalJSON is a package-level variable for testing the marshal
+// error path.
+var marshalJSON = json.Marshal
+
+// Publisher publishes messages to a NATS subject.
+type Publisher interface {
+ Publish(
+ ctx context.Context,
+ subject string,
+ data []byte,
+ ) error
+}
+
+// StreamStore implements Store backed by a NATS JetStream stream.
+type StreamStore struct {
+ stream jetstream.Stream
+ publisher Publisher
+ subject string
+ logger *slog.Logger
+}
+
+// NewStreamStore creates a new StreamStore with the given
+// dependencies. The subject is the base prefix (e.g., "audit");
+// messages are published to "audit.{id}".
+func NewStreamStore(
+ logger *slog.Logger,
+ stream jetstream.Stream,
+ publisher Publisher,
+ subject string,
+) *StreamStore {
+ return &StreamStore{
+ stream: stream,
+ publisher: publisher,
+ subject: subject,
+ logger: logger.With(slog.String("subsystem", "audit")),
+ }
+}
+
+// Write persists an audit entry to the stream.
+func (s *StreamStore) Write(
+ ctx context.Context,
+ entry Entry,
+) error {
+ data, err := marshalJSON(entry)
+ if err != nil {
+ return fmt.Errorf("marshal audit entry: %w", err)
+ }
+
+ subject := s.subject + "." + entry.ID
+ if err := s.publisher.Publish(ctx, subject, data); err != nil {
+ return fmt.Errorf("publish audit entry: %w", err)
+ }
+
+ return nil
+}
+
+// Get retrieves a single audit entry by ID using subject lookup.
+func (s *StreamStore) Get(
+ ctx context.Context,
+ id string,
+) (*Entry, error) {
+ subject := s.subject + "." + id
+
+ msg, err := s.stream.GetLastMsgForSubject(ctx, subject)
+ if err != nil {
+ return nil, fmt.Errorf(
+ "get audit entry: not found: %w",
+ err,
+ )
+ }
+
+ var entry Entry
+ if err := json.Unmarshal(msg.Data, &entry); err != nil {
+ return nil, fmt.Errorf("unmarshal audit entry: %w", err)
+ }
+
+ return &entry, nil
+}
+
+// List retrieves audit entries with pagination, newest first.
+// Uses the stream's message count for total and an ordered consumer
+// for efficient sequential reads.
+func (s *StreamStore) List(
+ ctx context.Context,
+ limit int,
+ offset int,
+) ([]Entry, int, error) {
+ info, err := s.stream.Info(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("get stream info: %w", err)
+ }
+
+ total := int(info.State.Msgs)
+ if total == 0 || offset >= total {
+ return []Entry{}, total, nil
+ }
+
+ // For newest-first: read from the end.
+ // We want entries at positions [total-offset-limit .. total-offset)
+ // mapped to stream sequences [first .. last].
+ startIdx := total - offset - limit
+ if startIdx < 0 {
+ startIdx = 0
+ }
+ count := total - offset - startIdx
+
+ startSeq := info.State.FirstSeq + uint64(startIdx)
+
+ consumer, err := s.stream.OrderedConsumer(
+ ctx,
+ jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverByStartSequencePolicy,
+ OptStartSeq: startSeq,
+ },
+ )
+ if err != nil {
+ return nil, 0, fmt.Errorf("create ordered consumer: %w", err)
+ }
+
+ entries := make([]Entry, 0, count)
+
+ fetchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ batch, err := consumer.Fetch(count, jetstream.FetchMaxWait(fetchTimeout))
+ if err != nil {
+ return nil, 0, fmt.Errorf("fetch audit entries: %w", err)
+ }
+
+ for msg := range batch.Messages() {
+ var entry Entry
+ if err := json.Unmarshal(msg.Data(), &entry); err != nil {
+ s.logger.Warn(
+ "failed to unmarshal audit entry",
+ slog.String("error", err.Error()),
+ )
+
+ continue
+ }
+
+ entries = append(entries, entry)
+ }
+
+ if batchErr := batch.Error(); batchErr != nil {
+ s.logger.Warn(
+ "batch fetch error",
+ slog.String("error", batchErr.Error()),
+ )
+ }
+
+ _ = fetchCtx
+
+ // Reverse for newest-first order.
+ for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
+ entries[i], entries[j] = entries[j], entries[i]
+ }
+
+ return entries, total, nil
+}
+
+// ListAll retrieves all audit entries, newest first.
+func (s *StreamStore) ListAll(
+ ctx context.Context,
+) ([]Entry, error) {
+ info, err := s.stream.Info(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get stream info: %w", err)
+ }
+
+ total := int(info.State.Msgs)
+ if total == 0 {
+ return []Entry{}, nil
+ }
+
+ consumer, err := s.stream.OrderedConsumer(
+ ctx,
+ jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverAllPolicy,
+ },
+ )
+ if err != nil {
+ return nil, fmt.Errorf("create ordered consumer: %w", err)
+ }
+
+ entries := make([]Entry, 0, total)
+
+ batch, err := consumer.Fetch(
+ total,
+ jetstream.FetchMaxWait(fetchTimeout),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("fetch audit entries: %w", err)
+ }
+
+ for msg := range batch.Messages() {
+ var entry Entry
+ if err := json.Unmarshal(msg.Data(), &entry); err != nil {
+ s.logger.Warn(
+ "failed to unmarshal audit entry",
+ slog.String("error", err.Error()),
+ )
+
+ continue
+ }
+
+ entries = append(entries, entry)
+ }
+
+ if batchErr := batch.Error(); batchErr != nil {
+ s.logger.Warn(
+ "batch fetch error",
+ slog.String("error", batchErr.Error()),
+ )
+ }
+
+ // Reverse for newest-first order.
+ for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
+ entries[i], entries[j] = entries[j], entries[i]
+ }
+
+ return entries, nil
+}
+```
+
+Also add a `fetchTimeout` constant at the top of the file:
+
+```go
+const fetchTimeout = 5 * time.Second
+```
+
+And add `"time"` to the imports.
+
+- [ ] **Step 2: Update export_test.go**
+
+The `export_test.go` file currently exports `SetMarshalJSON` /
+`ResetMarshalJSON` for the `marshalJSON` var in `kv_store.go`. Since
+`stream_store.go` declares the same `marshalJSON` var, the export test file
+works unchanged. Verify it still compiles.
+
+- [ ] **Step 3: Write the stream store tests**
+
+Create `internal/audit/stream_store_public_test.go`. Use gomock to mock
+`jetstream.Stream` and the `Publisher` interface. Follow the exact same test
+structure as `kv_store_public_test.go`:
+
+Test `Write`:
+
+- successfully publishes entry (mock publisher expects
+ `Publish(ctx, "audit.{id}", data)`)
+- returns error when publish fails
+- returns error when marshal fails (via `SetMarshalJSON`)
+
+Test `Get`:
+
+- successfully gets entry (mock stream `GetLastMsgForSubject` returns
+ `RawStreamMsg` with valid JSON data)
+- returns error containing "not found" when subject not found
+- returns error when unmarshal fails (bad JSON data)
+
+Test `List`:
+
+- returns all entries newest-first when within limit
+- applies pagination correctly (offset + limit)
+- returns empty when offset exceeds total
+- returns empty for empty stream (Info returns `State.Msgs == 0`)
+- returns error when stream info fails
+- skips entries when unmarshal fails (bad JSON in batch)
+
+Test `ListAll`:
+
+- returns all entries newest-first
+- returns empty for empty stream
+- returns error when stream info fails
+- skips entries when unmarshal fails
+
+For mocking `jetstream.Stream`: create a mock interface in
+`internal/audit/mocks/` using `go:generate mockgen`. The mock needs `Info`,
+`GetLastMsgForSubject`, and `OrderedConsumer` methods.
+
+For mocking the `Publisher` interface: add a `go:generate mockgen` directive for
+the `Publisher` interface defined in `stream_store.go`.
+
+For mocking `jetstream.Consumer` (returned by `OrderedConsumer`): mock its
+`Fetch` method which returns `MessageBatch`. Mock `MessageBatch` for its
+`Messages()` channel and `Error()` method.
+
+Target: **100% coverage** on `stream_store.go`.
+
+- [ ] **Step 4: Delete old KV store files**
+
+Delete:
+
+- `internal/audit/kv_store.go`
+- `internal/audit/kv_store_test.go`
+- `internal/audit/kv_store_public_test.go`
+
+- [ ] **Step 5: Regenerate mocks**
+
+Update `internal/audit/mocks/generate.go` to generate mocks for the new
+interfaces:
+
+```go
+//go:generate go tool github.com/golang/mock/mockgen -source=../store.go -destination=store.gen.go -package=mocks
+//go:generate go tool github.com/golang/mock/mockgen -source=../stream_store.go -destination=publisher.gen.go -package=mocks -mock_names=Publisher=MockPublisher
+```
+
+Run: `go generate ./internal/audit/mocks/...`
+
+Also generate mocks for the `jetstream.Stream` and `jetstream.Consumer`
+interfaces used in tests. These can live in `internal/audit/mocks/` or use
+`gomock`'s reflect mode for the `jetstream` package interfaces. Check how
+existing tests in the codebase mock `jetstream` interfaces (e.g., the
+`job/mocks` package) and follow the same pattern.
+
+- [ ] **Step 6: Run tests and check coverage**
+
+Run: `go test ./internal/audit/... -count=1 -coverprofile=/tmp/audit.out` Run:
+`go tool cover -func=/tmp/audit.out | grep stream_store`
+
+Expect: 100% coverage on `stream_store.go`.
+
+- [ ] **Step 7: Commit**
+
+```
+feat(audit): implement stream-based audit store
+```
+
+---
+
+### Task 5: Wire stream store in controller setup
+
+**Files:**
+
+- Modify: `cmd/controller_setup.go:640-658`
+
+- [ ] **Step 1: Replace createAuditStore to use stream**
+
+Replace the `createAuditStore` function:
+
+```go
+func createAuditStore(
+ ctx context.Context,
+ log *slog.Logger,
+ nc NATSClient,
+ namespace string,
+) (audit.Store, []api.Option) {
+ if appConfig.NATS.Audit.Stream == "" {
+ return nil, nil
+ }
+
+ auditStreamConfig := cli.BuildAuditStreamConfig(
+ namespace,
+ appConfig.NATS.Audit,
+ )
+ if err := nc.CreateOrUpdateStreamWithConfig(
+ ctx,
+ auditStreamConfig,
+ ); err != nil {
+ cli.LogFatal(log, "failed to create audit stream", err)
+ }
+
+ streamName := job.ApplyNamespaceToInfraName(
+ namespace,
+ appConfig.NATS.Audit.Stream,
+ )
+ stream, err := nc.Stream(ctx, streamName)
+ if err != nil {
+ cli.LogFatal(log, "failed to get audit stream", err)
+ }
+
+ subject := job.ApplyNamespaceToSubjects(
+ namespace,
+ appConfig.NATS.Audit.Subject,
+ )
+
+ store := audit.NewStreamStore(log, stream, nc, subject)
+
+ return store, []api.Option{api.WithAuditStore(store)}
+}
+```
+
+Note: `nc` (the `NATSClient`) satisfies `audit.Publisher` since it has a
+`Publish(ctx, subject, data) error` method. Verify the method signature matches.
+If the `NATSClient` interface's `Publish` method signature matches
+`audit.Publisher`, pass it directly. If not, create a thin adapter.
+
+- [ ] **Step 2: Verify build**
+
+Run: `go build ./...`
+
+Expect: clean build.
+
+- [ ] **Step 3: Run full test suite**
+
+Run: `just go::unit`
+
+Expect: all tests pass.
+
+- [ ] **Step 4: Commit**
+
+```
+feat(audit): wire stream store in controller setup
+```
+
+---
+
+### Task 6: Update documentation
+
+**Files:**
+
+- Modify: `docs/docs/sidebar/usage/configuration.md`
+- Modify: `docs/docs/sidebar/features/audit-logging.md`
+
+- [ ] **Step 1: Update configuration.md**
+
+Replace the `nats.audit` section in the full reference YAML:
+
+```yaml
+audit:
+ # JetStream stream name for audit log entries.
+ stream: 'AUDIT'
+ # Base subject prefix for audit messages.
+ subject: 'audit'
+ # Maximum age of audit entries (Go duration). Default 30 days.
+ max_age: '720h'
+ # Maximum total size of the audit stream in bytes.
+ max_bytes: 52428800 # 50 MiB
+ # Storage backend: "file" or "memory".
+ storage: 'file'
+ # Number of stream replicas.
+ replicas: 1
+```
+
+Update the `nats.audit` section reference table:
+
+| Key | Type | Description |
+| ----------- | ------ | ------------------------------------ |
+| `stream` | string | JetStream stream name for audit logs |
+| `subject` | string | Base subject prefix for audit msgs |
+| `max_age` | string | Maximum entry age (Go duration) |
+| `max_bytes` | int | Maximum stream size in bytes |
+| `storage` | string | `"file"` or `"memory"` |
+| `replicas` | int | Number of stream replicas |
+
+Update the environment variable table — replace `OSAPI_NATS_AUDIT_BUCKET` and
+`OSAPI_NATS_AUDIT_TTL` with `OSAPI_NATS_AUDIT_STREAM`,
+`OSAPI_NATS_AUDIT_SUBJECT`, and `OSAPI_NATS_AUDIT_MAX_AGE`.
+
+- [ ] **Step 2: Update audit-logging.md if it mentions KV**
+
+Check `docs/docs/sidebar/features/audit-logging.md` for references to "KV
+bucket" or "bucket" and update to "stream". Add a note about the `trace_id`
+field.
+
+- [ ] **Step 3: Commit**
+
+```
+docs(audit): update config reference for stream migration
+```
+
+---
+
+### Task 7: Final verification
+
+- [ ] **Step 1: Run full test suite with coverage**
+
+```bash
+go test ./internal/audit/... -coverprofile=/tmp/audit.out -count=1
+go tool cover -func=/tmp/audit.out | grep -v mocks
+```
+
+Verify 100% on `stream_store.go`.
+
+```bash
+go test ./internal/controller/api/audit/... -count=1
+go test ./internal/controller/api/ -run Audit -count=1
+go test ./pkg/sdk/client/ -run Audit -count=1
+```
+
+All must pass.
+
+- [ ] **Step 2: Build and lint**
+
+```bash
+go build ./...
+just go::vet
+```
+
+- [ ] **Step 3: Verify no references to old KV audit remain**
+
+```bash
+grep -r "AuditKV\|BuildAuditKVConfig\|NewKVStore\|audit.*bucket\|kv_store" \
+ --include="*.go" internal/ cmd/ pkg/ | grep -v _test.go | grep -v mocks
+```
+
+Expect: no matches.
+
+- [ ] **Step 4: Run docs formatting**
+
+```bash
+just docs::fmt-check
+```
+
+Fix any formatting issues.
diff --git a/docs/plans/2026-04-01-audit-stream-migration.md b/docs/plans/2026-04-01-audit-stream-migration.md
new file mode 100644
index 000000000..78a8d6a8e
--- /dev/null
+++ b/docs/plans/2026-04-01-audit-stream-migration.md
@@ -0,0 +1,152 @@
+# Audit Stream Migration
+
+Migrate the audit store from NATS KV to a JetStream stream for chronological
+ordering and efficient pagination.
+
+## Problem
+
+Audit entries are stored in a NATS KV bucket keyed by random UUID v4. `List()`
+fetches all keys into memory, sorts them (incorrectly — UUIDs don't sort
+chronologically), then paginates. With 1400+ entries and a 30-day TTL, this gets
+progressively slower and returns entries in random order.
+
+## Solution
+
+Replace the KV bucket with a JetStream stream. Use ULIDs as message subjects for
+chronological ordering and direct lookup. Add `trace_id` to audit entries for
+OpenTelemetry correlation.
+
+## Design
+
+### Config Change
+
+```yaml
+# Before
+nats:
+ audit:
+ bucket: 'audit-log'
+ ttl: '720h'
+ max_bytes: 52428800
+ storage: 'file'
+ replicas: 1
+
+# After
+nats:
+ audit:
+ stream: 'AUDIT'
+ subject: 'audit'
+ max_age: '720h'
+ max_bytes: 52428800
+ storage: 'file'
+ replicas: 1
+```
+
+Fields renamed: `bucket` -> `stream`, `ttl` -> `max_age`. New field: `subject`
+(base subject for audit messages). Drop `bucket` entirely.
+
+### Audit Entry
+
+Add one field to `Entry`:
+
+```go
+type Entry struct {
+ ID string `json:"id"`
+ Timestamp time.Time `json:"timestamp"`
+ User string `json:"user"`
+ Roles []string `json:"roles,omitempty"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ SourceIP string `json:"source_ip"`
+ ResponseCode int `json:"response_code"`
+ DurationMs int64 `json:"duration_ms"`
+ OperationID string `json:"operation_id,omitempty"`
+ TraceID string `json:"trace_id,omitempty"` // NEW
+}
+```
+
+The `ID` field changes from UUID to ULID. This is the only breaking change — no
+backward compatibility needed.
+
+### Store Interface
+
+The `Store` interface stays the same:
+
+```go
+type Store interface {
+ Write(ctx context.Context, entry Entry) error
+ Get(ctx context.Context, id string) (*Entry, error)
+ List(ctx context.Context, limit int, offset int) ([]Entry, int, error)
+ ListAll(ctx context.Context) ([]Entry, error)
+}
+```
+
+### Stream Store Operations
+
+| Operation | Implementation |
+| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Write | `js.Publish("audit.{ulid}", data)` |
+| Get | `stream.GetMsg(ctx, &GetMsgRequest{NextFor: "audit.{id}"})` |
+| List | `stream.Info()` for total count; ordered consumer with `DeliverByStartSequence` for pagination; read newest-first by computing start sequence from total - offset |
+| ListAll | Ordered consumer from sequence 1, read all messages forward |
+| Count | `stream.Info().State.Msgs` |
+
+### Middleware Change
+
+Extract trace ID from OpenTelemetry span context in the audit middleware:
+
+```go
+spanCtx := trace.SpanContextFromContext(c.Request().Context())
+if spanCtx.HasTraceID() {
+ entry.TraceID = spanCtx.TraceID().String()
+}
+```
+
+### Files Changed
+
+Production code:
+
+- `internal/audit/types.go` — add `TraceID` field to `Entry`
+- `internal/audit/stream_store.go` — new stream-based `Store` impl
+- `internal/audit/kv_store.go` — delete
+- `internal/audit/mocks/` — regenerate
+- `internal/config/types.go` — update audit config struct
+- `internal/controller/api/middleware_audit.go` — add trace ID, use ULID
+- `cmd/nats_setup.go` — create stream instead of KV bucket
+- `cmd/controller_setup.go` — wire stream store
+- `internal/controller/api/audit/gen/api.yaml` — add `trace_id` field
+- `internal/controller/api/audit/audit_list.go` — update `mapEntryToGen`
+- `internal/controller/api/audit/audit_get.go` — update if needed
+- `pkg/sdk/client/audit_types.go` — add `TraceID` to SDK types
+- `docs/docs/sidebar/usage/configuration.md` — update config reference
+
+Test code:
+
+- `internal/audit/stream_store_public_test.go` — new, 100% coverage
+- `internal/audit/kv_store_test.go` — delete
+- `internal/audit/kv_store_public_test.go` — delete
+- `internal/controller/api/middleware_audit_public_test.go` — update
+- Update all existing test files that reference changed types
+
+### Coverage Baseline
+
+All files below are currently at 100% coverage. The new implementation must
+maintain 100%:
+
+| File | Current |
+| --------------------------------------------- | ----------------------------- |
+| `internal/audit/kv_store.go` | 100% -> new `stream_store.go` |
+| `internal/audit/export/` | 100% |
+| `internal/controller/api/audit/` | 100% |
+| `internal/controller/api/middleware_audit.go` | 100% |
+| `pkg/sdk/client/audit.go` | 100% |
+| `pkg/sdk/client/audit_types.go` | 100% |
+
+### Not Changing
+
+- `internal/audit/export/` — export uses `ListAll()` via the `Store` interface,
+ no changes needed
+- OpenAPI spec for audit list/get/export — response shapes stay the same, just
+ add `trace_id` field
+- CLI commands — they consume SDK types, pick up `trace_id` via `--json`
+ automatically
+- Job KV, registry KV, state KV, facts KV — no changes
diff --git a/internal/audit/kv_store.go b/internal/audit/kv_store.go
deleted file mode 100644
index 596785b97..000000000
--- a/internal/audit/kv_store.go
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright (c) 2026 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package audit
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "sort"
-
- "github.com/nats-io/nats.go/jetstream"
-)
-
-// ensure KVStore implements Store at compile time.
-var _ Store = (*KVStore)(nil)
-
-// marshalJSON is a package-level variable for testing the marshal error path.
-var marshalJSON = json.Marshal
-
-// KVStore implements Store backed by a NATS KeyValue bucket.
-type KVStore struct {
- kv jetstream.KeyValue
- logger *slog.Logger
-}
-
-// NewKVStore creates a new KVStore.
-func NewKVStore(
- logger *slog.Logger,
- kv jetstream.KeyValue,
-) *KVStore {
- return &KVStore{
- kv: kv,
- logger: logger.With(slog.String("subsystem", "audit")),
- }
-}
-
-// Write persists an audit entry to the KV bucket.
-func (s *KVStore) Write(
- ctx context.Context,
- entry Entry,
-) error {
- data, err := marshalJSON(entry)
- if err != nil {
- return fmt.Errorf("marshal audit entry: %w", err)
- }
-
- if _, err := s.kv.Put(ctx, entry.ID, data); err != nil {
- return fmt.Errorf("put audit entry: %w", err)
- }
-
- return nil
-}
-
-// Get retrieves a single audit entry by ID.
-func (s *KVStore) Get(
- ctx context.Context,
- id string,
-) (*Entry, error) {
- kve, err := s.kv.Get(ctx, id)
- if err != nil {
- return nil, fmt.Errorf("get audit entry: %w", err)
- }
-
- var entry Entry
- if err := json.Unmarshal(kve.Value(), &entry); err != nil {
- return nil, fmt.Errorf("unmarshal audit entry: %w", err)
- }
-
- return &entry, nil
-}
-
-// List retrieves audit entries with pagination.
-func (s *KVStore) List(
- ctx context.Context,
- limit int,
- offset int,
-) ([]Entry, int, error) {
- keys, err := s.kv.Keys(ctx)
- if err != nil {
- // jetstream.ErrNoKeysFound means the bucket is empty
- if errors.Is(err, jetstream.ErrNoKeysFound) {
- return []Entry{}, 0, nil
- }
- return nil, 0, fmt.Errorf("list audit keys: %w", err)
- }
-
- total := len(keys)
-
- // Sort descending (newest first — ULIDs/UUIDs with timestamp prefix sort naturally)
- sort.Sort(sort.Reverse(sort.StringSlice(keys)))
-
- // Apply pagination
- if offset >= total {
- return []Entry{}, total, nil
- }
-
- end := offset + limit
- if end > total {
- end = total
- }
-
- pageKeys := keys[offset:end]
-
- entries := make([]Entry, 0, len(pageKeys))
- for _, key := range pageKeys {
- kve, err := s.kv.Get(ctx, key)
- if err != nil {
- s.logger.Warn(
- "failed to get audit entry",
- slog.String("key", key),
- slog.String("error", err.Error()),
- )
- continue
- }
-
- var entry Entry
- if err := json.Unmarshal(kve.Value(), &entry); err != nil {
- s.logger.Warn(
- "failed to unmarshal audit entry",
- slog.String("key", key),
- slog.String("error", err.Error()),
- )
- continue
- }
-
- entries = append(entries, entry)
- }
-
- return entries, total, nil
-}
-
-// ListAll retrieves all audit entries without pagination.
-func (s *KVStore) ListAll(
- ctx context.Context,
-) ([]Entry, error) {
- keys, err := s.kv.Keys(ctx)
- if err != nil {
- if errors.Is(err, jetstream.ErrNoKeysFound) {
- return []Entry{}, nil
- }
- return nil, fmt.Errorf("list audit keys: %w", err)
- }
-
- // Sort descending (newest first)
- sort.Sort(sort.Reverse(sort.StringSlice(keys)))
-
- entries := make([]Entry, 0, len(keys))
- for _, key := range keys {
- kve, err := s.kv.Get(ctx, key)
- if err != nil {
- s.logger.Warn(
- "failed to get audit entry",
- slog.String("key", key),
- slog.String("error", err.Error()),
- )
- continue
- }
-
- var entry Entry
- if err := json.Unmarshal(kve.Value(), &entry); err != nil {
- s.logger.Warn(
- "failed to unmarshal audit entry",
- slog.String("key", key),
- slog.String("error", err.Error()),
- )
- continue
- }
-
- entries = append(entries, entry)
- }
-
- return entries, nil
-}
diff --git a/internal/audit/kv_store_public_test.go b/internal/audit/kv_store_public_test.go
deleted file mode 100644
index 16eae1cbd..000000000
--- a/internal/audit/kv_store_public_test.go
+++ /dev/null
@@ -1,422 +0,0 @@
-// Copyright (c) 2026 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package audit_test
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "testing"
- "time"
-
- "github.com/golang/mock/gomock"
- "github.com/nats-io/nats.go/jetstream"
- "github.com/stretchr/testify/suite"
-
- "github.com/retr0h/osapi/internal/audit"
- "github.com/retr0h/osapi/internal/job/mocks"
-)
-
-type KVStorePublicTestSuite struct {
- suite.Suite
-
- ctrl *gomock.Controller
- mockKV *mocks.MockKeyValue
- store *audit.KVStore
-}
-
-func (s *KVStorePublicTestSuite) SetupTest() {
- s.ctrl = gomock.NewController(s.T())
- s.mockKV = mocks.NewMockKeyValue(s.ctrl)
- s.store = audit.NewKVStore(slog.Default(), s.mockKV)
-}
-
-func (s *KVStorePublicTestSuite) TearDownTest() {
- s.ctrl.Finish()
-}
-
-func (s *KVStorePublicTestSuite) newEntry(
- id string,
-) audit.Entry {
- return audit.Entry{
- ID: id,
- Timestamp: time.Now(),
- User: "user@example.com",
- Roles: []string{"admin"},
- Method: "GET",
- Path: "/node/hostname",
- SourceIP: "127.0.0.1",
- ResponseCode: 200,
- DurationMs: 42,
- }
-}
-
-func (s *KVStorePublicTestSuite) TestWrite() {
- tests := []struct {
- name string
- entry audit.Entry
- setupMock func()
- wantErr bool
- }{
- {
- name: "successfully writes entry",
- entry: s.newEntry("entry-1"),
- setupMock: func() {
- s.mockKV.EXPECT().
- Put(gomock.Any(), "entry-1", gomock.Any()).
- Return(uint64(1), nil)
- },
- wantErr: false,
- },
- {
- name: "returns error when put fails",
- entry: s.newEntry("entry-2"),
- setupMock: func() {
- s.mockKV.EXPECT().
- Put(gomock.Any(), "entry-2", gomock.Any()).
- Return(uint64(0), fmt.Errorf("kv error"))
- },
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- tt.setupMock()
- err := s.store.Write(context.Background(), tt.entry)
- if tt.wantErr {
- s.Error(err)
- } else {
- s.NoError(err)
- }
- })
- }
-}
-
-func (s *KVStorePublicTestSuite) TestGet() {
- entry := s.newEntry("entry-1")
- data, _ := json.Marshal(entry)
-
- tests := []struct {
- name string
- id string
- setupMock func()
- validate func(*audit.Entry, error)
- }{
- {
- name: "successfully gets entry",
- id: "entry-1",
- setupMock: func() {
- mockEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- mockEntry.EXPECT().Value().Return(data)
- s.mockKV.EXPECT().Get(gomock.Any(), "entry-1").Return(mockEntry, nil)
- },
- validate: func(e *audit.Entry, err error) {
- s.NoError(err)
- s.Require().NotNil(e)
- s.Equal("entry-1", e.ID)
- s.Equal("user@example.com", e.User)
- },
- },
- {
- name: "returns error when key not found",
- id: "missing",
- setupMock: func() {
- s.mockKV.EXPECT().Get(gomock.Any(), "missing").Return(nil, jetstream.ErrKeyNotFound)
- },
- validate: func(e *audit.Entry, err error) {
- s.Error(err)
- s.Nil(e)
- },
- },
- {
- name: "returns error when unmarshal fails",
- id: "bad-json",
- setupMock: func() {
- mockEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- mockEntry.EXPECT().Value().Return([]byte("not-json"))
- s.mockKV.EXPECT().Get(gomock.Any(), "bad-json").Return(mockEntry, nil)
- },
- validate: func(e *audit.Entry, err error) {
- s.Error(err)
- s.Nil(e)
- s.Contains(err.Error(), "unmarshal audit entry")
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- tt.setupMock()
- result, err := s.store.Get(context.Background(), tt.id)
- tt.validate(result, err)
- })
- }
-}
-
-func (s *KVStorePublicTestSuite) TestList() {
- entry1 := s.newEntry("aaa")
- entry2 := s.newEntry("bbb")
- entry3 := s.newEntry("ccc")
- data1, _ := json.Marshal(entry1)
- data2, _ := json.Marshal(entry2)
- data3, _ := json.Marshal(entry3)
-
- tests := []struct {
- name string
- limit int
- offset int
- setupMock func()
- validate func([]audit.Entry, int, error)
- }{
- {
- name: "returns all entries when within limit",
- limit: 10,
- offset: 0,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb", "ccc"}, nil)
- me1 := mocks.NewMockKeyValueEntry(s.ctrl)
- me1.EXPECT().Value().Return(data3)
- me2 := mocks.NewMockKeyValueEntry(s.ctrl)
- me2.EXPECT().Value().Return(data2)
- me3 := mocks.NewMockKeyValueEntry(s.ctrl)
- me3.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "ccc").Return(me1, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(me2, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(me3, nil)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(3, total)
- s.Len(entries, 3)
- // Sorted descending
- s.Equal("ccc", entries[0].ID)
- s.Equal("bbb", entries[1].ID)
- s.Equal("aaa", entries[2].ID)
- },
- },
- {
- name: "applies pagination correctly",
- limit: 1,
- offset: 1,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb", "ccc"}, nil)
- me := mocks.NewMockKeyValueEntry(s.ctrl)
- me.EXPECT().Value().Return(data2)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(me, nil)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(3, total)
- s.Len(entries, 1)
- s.Equal("bbb", entries[0].ID)
- },
- },
- {
- name: "returns empty when offset exceeds total",
- limit: 10,
- offset: 100,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa"}, nil)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(1, total)
- s.Empty(entries)
- },
- },
- {
- name: "returns empty for empty bucket",
- limit: 10,
- offset: 0,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return(nil, jetstream.ErrNoKeysFound)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(0, total)
- s.Empty(entries)
- },
- },
- {
- name: "returns error when keys fails",
- limit: 10,
- offset: 0,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return(nil, fmt.Errorf("connection error"))
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.Error(err)
- s.Nil(entries)
- s.Equal(0, total)
- },
- },
- {
- name: "skips entry when individual get fails",
- limit: 10,
- offset: 0,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb"}, nil)
- me1 := mocks.NewMockKeyValueEntry(s.ctrl)
- me1.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(nil, fmt.Errorf("get error"))
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(me1, nil)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(2, total)
- s.Len(entries, 1)
- s.Equal("aaa", entries[0].ID)
- },
- },
- {
- name: "skips entry when unmarshal fails",
- limit: 10,
- offset: 0,
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb"}, nil)
- badEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- badEntry.EXPECT().Value().Return([]byte("not-json"))
- goodEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- goodEntry.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(badEntry, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(goodEntry, nil)
- },
- validate: func(entries []audit.Entry, total int, err error) {
- s.NoError(err)
- s.Equal(2, total)
- s.Len(entries, 1)
- s.Equal("aaa", entries[0].ID)
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- tt.setupMock()
- entries, total, err := s.store.List(context.Background(), tt.limit, tt.offset)
- tt.validate(entries, total, err)
- })
- }
-}
-
-func (s *KVStorePublicTestSuite) TestListAll() {
- entry1 := s.newEntry("aaa")
- entry2 := s.newEntry("bbb")
- entry3 := s.newEntry("ccc")
- data1, _ := json.Marshal(entry1)
- data2, _ := json.Marshal(entry2)
- data3, _ := json.Marshal(entry3)
-
- tests := []struct {
- name string
- setupMock func()
- validate func([]audit.Entry, error)
- }{
- {
- name: "returns all entries sorted descending",
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb", "ccc"}, nil)
- me1 := mocks.NewMockKeyValueEntry(s.ctrl)
- me1.EXPECT().Value().Return(data3)
- me2 := mocks.NewMockKeyValueEntry(s.ctrl)
- me2.EXPECT().Value().Return(data2)
- me3 := mocks.NewMockKeyValueEntry(s.ctrl)
- me3.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "ccc").Return(me1, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(me2, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(me3, nil)
- },
- validate: func(entries []audit.Entry, err error) {
- s.NoError(err)
- s.Len(entries, 3)
- s.Equal("ccc", entries[0].ID)
- s.Equal("bbb", entries[1].ID)
- s.Equal("aaa", entries[2].ID)
- },
- },
- {
- name: "returns empty for empty bucket",
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return(nil, jetstream.ErrNoKeysFound)
- },
- validate: func(entries []audit.Entry, err error) {
- s.NoError(err)
- s.Empty(entries)
- },
- },
- {
- name: "returns error when keys fails",
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return(nil, fmt.Errorf("connection error"))
- },
- validate: func(entries []audit.Entry, err error) {
- s.Error(err)
- s.Nil(entries)
- },
- },
- {
- name: "skips entry when individual get fails",
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb"}, nil)
- me1 := mocks.NewMockKeyValueEntry(s.ctrl)
- me1.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(nil, fmt.Errorf("get error"))
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(me1, nil)
- },
- validate: func(entries []audit.Entry, err error) {
- s.NoError(err)
- s.Len(entries, 1)
- s.Equal("aaa", entries[0].ID)
- },
- },
- {
- name: "skips entry when unmarshal fails",
- setupMock: func() {
- s.mockKV.EXPECT().Keys(gomock.Any()).Return([]string{"aaa", "bbb"}, nil)
- badEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- badEntry.EXPECT().Value().Return([]byte("not-json"))
- goodEntry := mocks.NewMockKeyValueEntry(s.ctrl)
- goodEntry.EXPECT().Value().Return(data1)
- s.mockKV.EXPECT().Get(gomock.Any(), "bbb").Return(badEntry, nil)
- s.mockKV.EXPECT().Get(gomock.Any(), "aaa").Return(goodEntry, nil)
- },
- validate: func(entries []audit.Entry, err error) {
- s.NoError(err)
- s.Len(entries, 1)
- s.Equal("aaa", entries[0].ID)
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- tt.setupMock()
- entries, err := s.store.ListAll(context.Background())
- tt.validate(entries, err)
- })
- }
-}
-
-func TestKVStorePublicTestSuite(t *testing.T) {
- suite.Run(t, new(KVStorePublicTestSuite))
-}
diff --git a/internal/audit/kv_store_test.go b/internal/audit/kv_store_test.go
deleted file mode 100644
index e31366f28..000000000
--- a/internal/audit/kv_store_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-// Copyright (c) 2026 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package audit_test
-
-import (
- "context"
- "fmt"
- "log/slog"
- "testing"
-
- "github.com/golang/mock/gomock"
- "github.com/stretchr/testify/suite"
-
- "github.com/retr0h/osapi/internal/audit"
- "github.com/retr0h/osapi/internal/job/mocks"
-)
-
-type KVStoreMarshalTestSuite struct {
- suite.Suite
-
- ctrl *gomock.Controller
- mockKV *mocks.MockKeyValue
- store *audit.KVStore
-}
-
-func (s *KVStoreMarshalTestSuite) SetupTest() {
- s.ctrl = gomock.NewController(s.T())
- s.mockKV = mocks.NewMockKeyValue(s.ctrl)
- s.store = audit.NewKVStore(slog.Default(), s.mockKV)
-}
-
-func (s *KVStoreMarshalTestSuite) TearDownTest() {
- s.ctrl.Finish()
- audit.ResetMarshalJSON()
-}
-
-func (s *KVStoreMarshalTestSuite) TestWriteMarshalError() {
- tests := []struct {
- name string
- setupMock func()
- validateFunc func(err error)
- }{
- {
- name: "when marshal fails returns wrapped error",
- setupMock: func() {
- audit.SetMarshalJSON(func(_ interface{}) ([]byte, error) {
- return nil, fmt.Errorf("marshal failure")
- })
- },
- validateFunc: func(err error) {
- s.Error(err)
- s.Contains(err.Error(), "marshal audit entry")
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- tt.setupMock()
- err := s.store.Write(context.Background(), audit.Entry{ID: "test-id"})
- tt.validateFunc(err)
- })
- }
-}
-
-func TestKVStoreMarshalTestSuite(t *testing.T) {
- suite.Run(t, new(KVStoreMarshalTestSuite))
-}
diff --git a/internal/audit/mocks/consumer.gen.go b/internal/audit/mocks/consumer.gen.go
new file mode 100644
index 000000000..d35fedb0a
--- /dev/null
+++ b/internal/audit/mocks/consumer.gen.go
@@ -0,0 +1,178 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/nats-io/nats.go/jetstream (interfaces: Consumer)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "github.com/golang/mock/gomock"
+ jetstream "github.com/nats-io/nats.go/jetstream"
+)
+
+// MockConsumer is a mock of Consumer interface.
+type MockConsumer struct {
+ ctrl *gomock.Controller
+ recorder *MockConsumerMockRecorder
+}
+
+// MockConsumerMockRecorder is the mock recorder for MockConsumer.
+type MockConsumerMockRecorder struct {
+ mock *MockConsumer
+}
+
+// NewMockConsumer creates a new mock instance.
+func NewMockConsumer(ctrl *gomock.Controller) *MockConsumer {
+ mock := &MockConsumer{ctrl: ctrl}
+ mock.recorder = &MockConsumerMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockConsumer) EXPECT() *MockConsumerMockRecorder {
+ return m.recorder
+}
+
+// CachedInfo mocks base method.
+func (m *MockConsumer) CachedInfo() *jetstream.ConsumerInfo {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CachedInfo")
+ ret0, _ := ret[0].(*jetstream.ConsumerInfo)
+ return ret0
+}
+
+// CachedInfo indicates an expected call of CachedInfo.
+func (mr *MockConsumerMockRecorder) CachedInfo() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CachedInfo", reflect.TypeOf((*MockConsumer)(nil).CachedInfo))
+}
+
+// Consume mocks base method.
+func (m *MockConsumer) Consume(arg0 jetstream.MessageHandler, arg1 ...jetstream.PullConsumeOpt) (jetstream.ConsumeContext, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Consume", varargs...)
+ ret0, _ := ret[0].(jetstream.ConsumeContext)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Consume indicates an expected call of Consume.
+func (mr *MockConsumerMockRecorder) Consume(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Consume", reflect.TypeOf((*MockConsumer)(nil).Consume), varargs...)
+}
+
+// Fetch mocks base method.
+func (m *MockConsumer) Fetch(arg0 int, arg1 ...jetstream.FetchOpt) (jetstream.MessageBatch, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Fetch", varargs...)
+ ret0, _ := ret[0].(jetstream.MessageBatch)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Fetch indicates an expected call of Fetch.
+func (mr *MockConsumerMockRecorder) Fetch(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fetch", reflect.TypeOf((*MockConsumer)(nil).Fetch), varargs...)
+}
+
+// FetchBytes mocks base method.
+func (m *MockConsumer) FetchBytes(arg0 int, arg1 ...jetstream.FetchOpt) (jetstream.MessageBatch, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "FetchBytes", varargs...)
+ ret0, _ := ret[0].(jetstream.MessageBatch)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// FetchBytes indicates an expected call of FetchBytes.
+func (mr *MockConsumerMockRecorder) FetchBytes(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchBytes", reflect.TypeOf((*MockConsumer)(nil).FetchBytes), varargs...)
+}
+
+// FetchNoWait mocks base method.
+func (m *MockConsumer) FetchNoWait(arg0 int) (jetstream.MessageBatch, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "FetchNoWait", arg0)
+ ret0, _ := ret[0].(jetstream.MessageBatch)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// FetchNoWait indicates an expected call of FetchNoWait.
+func (mr *MockConsumerMockRecorder) FetchNoWait(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FetchNoWait", reflect.TypeOf((*MockConsumer)(nil).FetchNoWait), arg0)
+}
+
+// Info mocks base method.
+func (m *MockConsumer) Info(arg0 context.Context) (*jetstream.ConsumerInfo, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Info", arg0)
+ ret0, _ := ret[0].(*jetstream.ConsumerInfo)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Info indicates an expected call of Info.
+func (mr *MockConsumerMockRecorder) Info(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockConsumer)(nil).Info), arg0)
+}
+
+// Messages mocks base method.
+func (m *MockConsumer) Messages(arg0 ...jetstream.PullMessagesOpt) (jetstream.MessagesContext, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{}
+ for _, a := range arg0 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Messages", varargs...)
+ ret0, _ := ret[0].(jetstream.MessagesContext)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Messages indicates an expected call of Messages.
+func (mr *MockConsumerMockRecorder) Messages(arg0 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Messages", reflect.TypeOf((*MockConsumer)(nil).Messages), arg0...)
+}
+
+// Next mocks base method.
+func (m *MockConsumer) Next(arg0 ...jetstream.FetchOpt) (jetstream.Msg, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{}
+ for _, a := range arg0 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Next", varargs...)
+ ret0, _ := ret[0].(jetstream.Msg)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Next indicates an expected call of Next.
+func (mr *MockConsumerMockRecorder) Next(arg0 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Next", reflect.TypeOf((*MockConsumer)(nil).Next), arg0...)
+}
diff --git a/internal/audit/mocks/generate.go b/internal/audit/mocks/generate.go
index f805c6de2..f4b1a476a 100644
--- a/internal/audit/mocks/generate.go
+++ b/internal/audit/mocks/generate.go
@@ -22,3 +22,8 @@
package mocks
//go:generate go tool github.com/golang/mock/mockgen -source=../store.go -destination=store.gen.go -package=mocks
+//go:generate go tool github.com/golang/mock/mockgen -source=../stream_store.go -destination=publisher.gen.go -package=mocks
+//go:generate go tool github.com/golang/mock/mockgen -destination=stream.gen.go -package=mocks github.com/nats-io/nats.go/jetstream Stream
+//go:generate go tool github.com/golang/mock/mockgen -destination=consumer.gen.go -package=mocks github.com/nats-io/nats.go/jetstream Consumer
+//go:generate go tool github.com/golang/mock/mockgen -destination=message_batch.gen.go -package=mocks github.com/nats-io/nats.go/jetstream MessageBatch
+//go:generate go tool github.com/golang/mock/mockgen -destination=msg.gen.go -package=mocks github.com/nats-io/nats.go/jetstream Msg
diff --git a/internal/audit/mocks/message_batch.gen.go b/internal/audit/mocks/message_batch.gen.go
new file mode 100644
index 000000000..9d73e5ab4
--- /dev/null
+++ b/internal/audit/mocks/message_batch.gen.go
@@ -0,0 +1,63 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/nats-io/nats.go/jetstream (interfaces: MessageBatch)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ reflect "reflect"
+
+ gomock "github.com/golang/mock/gomock"
+ jetstream "github.com/nats-io/nats.go/jetstream"
+)
+
+// MockMessageBatch is a mock of MessageBatch interface.
+type MockMessageBatch struct {
+ ctrl *gomock.Controller
+ recorder *MockMessageBatchMockRecorder
+}
+
+// MockMessageBatchMockRecorder is the mock recorder for MockMessageBatch.
+type MockMessageBatchMockRecorder struct {
+ mock *MockMessageBatch
+}
+
+// NewMockMessageBatch creates a new mock instance.
+func NewMockMessageBatch(ctrl *gomock.Controller) *MockMessageBatch {
+ mock := &MockMessageBatch{ctrl: ctrl}
+ mock.recorder = &MockMessageBatchMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockMessageBatch) EXPECT() *MockMessageBatchMockRecorder {
+ return m.recorder
+}
+
+// Error mocks base method.
+func (m *MockMessageBatch) Error() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Error")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Error indicates an expected call of Error.
+func (mr *MockMessageBatchMockRecorder) Error() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockMessageBatch)(nil).Error))
+}
+
+// Messages mocks base method.
+func (m *MockMessageBatch) Messages() <-chan jetstream.Msg {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Messages")
+ ret0, _ := ret[0].(<-chan jetstream.Msg)
+ return ret0
+}
+
+// Messages indicates an expected call of Messages.
+func (mr *MockMessageBatchMockRecorder) Messages() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Messages", reflect.TypeOf((*MockMessageBatch)(nil).Messages))
+}
diff --git a/internal/audit/mocks/msg.gen.go b/internal/audit/mocks/msg.gen.go
new file mode 100644
index 000000000..9eff055a4
--- /dev/null
+++ b/internal/audit/mocks/msg.gen.go
@@ -0,0 +1,207 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/nats-io/nats.go/jetstream (interfaces: Msg)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+ time "time"
+
+ gomock "github.com/golang/mock/gomock"
+ nats "github.com/nats-io/nats.go"
+ jetstream "github.com/nats-io/nats.go/jetstream"
+)
+
+// MockMsg is a mock of Msg interface.
+type MockMsg struct {
+ ctrl *gomock.Controller
+ recorder *MockMsgMockRecorder
+}
+
+// MockMsgMockRecorder is the mock recorder for MockMsg.
+type MockMsgMockRecorder struct {
+ mock *MockMsg
+}
+
+// NewMockMsg creates a new mock instance.
+func NewMockMsg(ctrl *gomock.Controller) *MockMsg {
+ mock := &MockMsg{ctrl: ctrl}
+ mock.recorder = &MockMsgMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockMsg) EXPECT() *MockMsgMockRecorder {
+ return m.recorder
+}
+
+// Ack mocks base method.
+func (m *MockMsg) Ack() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Ack")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Ack indicates an expected call of Ack.
+func (mr *MockMsgMockRecorder) Ack() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Ack", reflect.TypeOf((*MockMsg)(nil).Ack))
+}
+
+// Data mocks base method.
+func (m *MockMsg) Data() []byte {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Data")
+ ret0, _ := ret[0].([]byte)
+ return ret0
+}
+
+// Data indicates an expected call of Data.
+func (mr *MockMsgMockRecorder) Data() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Data", reflect.TypeOf((*MockMsg)(nil).Data))
+}
+
+// DoubleAck mocks base method.
+func (m *MockMsg) DoubleAck(arg0 context.Context) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DoubleAck", arg0)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DoubleAck indicates an expected call of DoubleAck.
+func (mr *MockMsgMockRecorder) DoubleAck(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DoubleAck", reflect.TypeOf((*MockMsg)(nil).DoubleAck), arg0)
+}
+
+// Headers mocks base method.
+func (m *MockMsg) Headers() nats.Header {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Headers")
+ ret0, _ := ret[0].(nats.Header)
+ return ret0
+}
+
+// Headers indicates an expected call of Headers.
+func (mr *MockMsgMockRecorder) Headers() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Headers", reflect.TypeOf((*MockMsg)(nil).Headers))
+}
+
+// InProgress mocks base method.
+func (m *MockMsg) InProgress() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "InProgress")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// InProgress indicates an expected call of InProgress.
+func (mr *MockMsgMockRecorder) InProgress() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InProgress", reflect.TypeOf((*MockMsg)(nil).InProgress))
+}
+
+// Metadata mocks base method.
+func (m *MockMsg) Metadata() (*jetstream.MsgMetadata, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Metadata")
+ ret0, _ := ret[0].(*jetstream.MsgMetadata)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Metadata indicates an expected call of Metadata.
+func (mr *MockMsgMockRecorder) Metadata() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Metadata", reflect.TypeOf((*MockMsg)(nil).Metadata))
+}
+
+// Nak mocks base method.
+func (m *MockMsg) Nak() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Nak")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Nak indicates an expected call of Nak.
+func (mr *MockMsgMockRecorder) Nak() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Nak", reflect.TypeOf((*MockMsg)(nil).Nak))
+}
+
+// NakWithDelay mocks base method.
+func (m *MockMsg) NakWithDelay(arg0 time.Duration) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "NakWithDelay", arg0)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// NakWithDelay indicates an expected call of NakWithDelay.
+func (mr *MockMsgMockRecorder) NakWithDelay(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NakWithDelay", reflect.TypeOf((*MockMsg)(nil).NakWithDelay), arg0)
+}
+
+// Reply mocks base method.
+func (m *MockMsg) Reply() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Reply")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// Reply indicates an expected call of Reply.
+func (mr *MockMsgMockRecorder) Reply() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Reply", reflect.TypeOf((*MockMsg)(nil).Reply))
+}
+
+// Subject mocks base method.
+func (m *MockMsg) Subject() string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Subject")
+ ret0, _ := ret[0].(string)
+ return ret0
+}
+
+// Subject indicates an expected call of Subject.
+func (mr *MockMsgMockRecorder) Subject() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Subject", reflect.TypeOf((*MockMsg)(nil).Subject))
+}
+
+// Term mocks base method.
+func (m *MockMsg) Term() error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Term")
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Term indicates an expected call of Term.
+func (mr *MockMsgMockRecorder) Term() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Term", reflect.TypeOf((*MockMsg)(nil).Term))
+}
+
+// TermWithReason mocks base method.
+func (m *MockMsg) TermWithReason(arg0 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "TermWithReason", arg0)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// TermWithReason indicates an expected call of TermWithReason.
+func (mr *MockMsgMockRecorder) TermWithReason(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TermWithReason", reflect.TypeOf((*MockMsg)(nil).TermWithReason), arg0)
+}
diff --git a/internal/audit/mocks/publisher.gen.go b/internal/audit/mocks/publisher.gen.go
new file mode 100644
index 000000000..7935a54f7
--- /dev/null
+++ b/internal/audit/mocks/publisher.gen.go
@@ -0,0 +1,49 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ../stream_store.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+
+ gomock "github.com/golang/mock/gomock"
+)
+
+// MockPublisher is a mock of Publisher interface.
+type MockPublisher struct {
+ ctrl *gomock.Controller
+ recorder *MockPublisherMockRecorder
+}
+
+// MockPublisherMockRecorder is the mock recorder for MockPublisher.
+type MockPublisherMockRecorder struct {
+ mock *MockPublisher
+}
+
+// NewMockPublisher creates a new mock instance.
+func NewMockPublisher(ctrl *gomock.Controller) *MockPublisher {
+ mock := &MockPublisher{ctrl: ctrl}
+ mock.recorder = &MockPublisherMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockPublisher) EXPECT() *MockPublisherMockRecorder {
+ return m.recorder
+}
+
+// Publish mocks base method.
+func (m *MockPublisher) Publish(ctx context.Context, subject string, data []byte) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Publish", ctx, subject, data)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Publish indicates an expected call of Publish.
+func (mr *MockPublisherMockRecorder) Publish(ctx, subject, data interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Publish", reflect.TypeOf((*MockPublisher)(nil).Publish), ctx, subject, data)
+}
diff --git a/internal/audit/mocks/stream.gen.go b/internal/audit/mocks/stream.gen.go
new file mode 100644
index 000000000..6707346a5
--- /dev/null
+++ b/internal/audit/mocks/stream.gen.go
@@ -0,0 +1,374 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/nats-io/nats.go/jetstream (interfaces: Stream)
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+ context "context"
+ reflect "reflect"
+ time "time"
+
+ gomock "github.com/golang/mock/gomock"
+ jetstream "github.com/nats-io/nats.go/jetstream"
+)
+
+// MockStream is a mock of Stream interface.
+type MockStream struct {
+ ctrl *gomock.Controller
+ recorder *MockStreamMockRecorder
+}
+
+// MockStreamMockRecorder is the mock recorder for MockStream.
+type MockStreamMockRecorder struct {
+ mock *MockStream
+}
+
+// NewMockStream creates a new mock instance.
+func NewMockStream(ctrl *gomock.Controller) *MockStream {
+ mock := &MockStream{ctrl: ctrl}
+ mock.recorder = &MockStreamMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockStream) EXPECT() *MockStreamMockRecorder {
+ return m.recorder
+}
+
+// CachedInfo mocks base method.
+func (m *MockStream) CachedInfo() *jetstream.StreamInfo {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CachedInfo")
+ ret0, _ := ret[0].(*jetstream.StreamInfo)
+ return ret0
+}
+
+// CachedInfo indicates an expected call of CachedInfo.
+func (mr *MockStreamMockRecorder) CachedInfo() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CachedInfo", reflect.TypeOf((*MockStream)(nil).CachedInfo))
+}
+
+// Consumer mocks base method.
+func (m *MockStream) Consumer(arg0 context.Context, arg1 string) (jetstream.Consumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Consumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.Consumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Consumer indicates an expected call of Consumer.
+func (mr *MockStreamMockRecorder) Consumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Consumer", reflect.TypeOf((*MockStream)(nil).Consumer), arg0, arg1)
+}
+
+// ConsumerNames mocks base method.
+func (m *MockStream) ConsumerNames(arg0 context.Context) jetstream.ConsumerNameLister {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ConsumerNames", arg0)
+ ret0, _ := ret[0].(jetstream.ConsumerNameLister)
+ return ret0
+}
+
+// ConsumerNames indicates an expected call of ConsumerNames.
+func (mr *MockStreamMockRecorder) ConsumerNames(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ConsumerNames", reflect.TypeOf((*MockStream)(nil).ConsumerNames), arg0)
+}
+
+// CreateConsumer mocks base method.
+func (m *MockStream) CreateConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.Consumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.Consumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CreateConsumer indicates an expected call of CreateConsumer.
+func (mr *MockStreamMockRecorder) CreateConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateConsumer", reflect.TypeOf((*MockStream)(nil).CreateConsumer), arg0, arg1)
+}
+
+// CreateOrUpdateConsumer mocks base method.
+func (m *MockStream) CreateOrUpdateConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.Consumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateOrUpdateConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.Consumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CreateOrUpdateConsumer indicates an expected call of CreateOrUpdateConsumer.
+func (mr *MockStreamMockRecorder) CreateOrUpdateConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdateConsumer", reflect.TypeOf((*MockStream)(nil).CreateOrUpdateConsumer), arg0, arg1)
+}
+
+// CreateOrUpdatePushConsumer mocks base method.
+func (m *MockStream) CreateOrUpdatePushConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.PushConsumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreateOrUpdatePushConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.PushConsumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CreateOrUpdatePushConsumer indicates an expected call of CreateOrUpdatePushConsumer.
+func (mr *MockStreamMockRecorder) CreateOrUpdatePushConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdatePushConsumer", reflect.TypeOf((*MockStream)(nil).CreateOrUpdatePushConsumer), arg0, arg1)
+}
+
+// CreatePushConsumer mocks base method.
+func (m *MockStream) CreatePushConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.PushConsumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CreatePushConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.PushConsumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CreatePushConsumer indicates an expected call of CreatePushConsumer.
+func (mr *MockStreamMockRecorder) CreatePushConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePushConsumer", reflect.TypeOf((*MockStream)(nil).CreatePushConsumer), arg0, arg1)
+}
+
+// DeleteConsumer mocks base method.
+func (m *MockStream) DeleteConsumer(arg0 context.Context, arg1 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeleteConsumer", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeleteConsumer indicates an expected call of DeleteConsumer.
+func (mr *MockStreamMockRecorder) DeleteConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteConsumer", reflect.TypeOf((*MockStream)(nil).DeleteConsumer), arg0, arg1)
+}
+
+// DeleteMsg mocks base method.
+func (m *MockStream) DeleteMsg(arg0 context.Context, arg1 uint64) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "DeleteMsg", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// DeleteMsg indicates an expected call of DeleteMsg.
+func (mr *MockStreamMockRecorder) DeleteMsg(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteMsg", reflect.TypeOf((*MockStream)(nil).DeleteMsg), arg0, arg1)
+}
+
+// GetLastMsgForSubject mocks base method.
+func (m *MockStream) GetLastMsgForSubject(arg0 context.Context, arg1 string) (*jetstream.RawStreamMsg, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetLastMsgForSubject", arg0, arg1)
+ ret0, _ := ret[0].(*jetstream.RawStreamMsg)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetLastMsgForSubject indicates an expected call of GetLastMsgForSubject.
+func (mr *MockStreamMockRecorder) GetLastMsgForSubject(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLastMsgForSubject", reflect.TypeOf((*MockStream)(nil).GetLastMsgForSubject), arg0, arg1)
+}
+
+// GetMsg mocks base method.
+func (m *MockStream) GetMsg(arg0 context.Context, arg1 uint64, arg2 ...jetstream.GetMsgOpt) (*jetstream.RawStreamMsg, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0, arg1}
+ for _, a := range arg2 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "GetMsg", varargs...)
+ ret0, _ := ret[0].(*jetstream.RawStreamMsg)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetMsg indicates an expected call of GetMsg.
+func (mr *MockStreamMockRecorder) GetMsg(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0, arg1}, arg2...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMsg", reflect.TypeOf((*MockStream)(nil).GetMsg), varargs...)
+}
+
+// Info mocks base method.
+func (m *MockStream) Info(arg0 context.Context, arg1 ...jetstream.StreamInfoOpt) (*jetstream.StreamInfo, error) {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Info", varargs...)
+ ret0, _ := ret[0].(*jetstream.StreamInfo)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// Info indicates an expected call of Info.
+func (mr *MockStreamMockRecorder) Info(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockStream)(nil).Info), varargs...)
+}
+
+// ListConsumers mocks base method.
+func (m *MockStream) ListConsumers(arg0 context.Context) jetstream.ConsumerInfoLister {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ListConsumers", arg0)
+ ret0, _ := ret[0].(jetstream.ConsumerInfoLister)
+ return ret0
+}
+
+// ListConsumers indicates an expected call of ListConsumers.
+func (mr *MockStreamMockRecorder) ListConsumers(arg0 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListConsumers", reflect.TypeOf((*MockStream)(nil).ListConsumers), arg0)
+}
+
+// OrderedConsumer mocks base method.
+func (m *MockStream) OrderedConsumer(arg0 context.Context, arg1 jetstream.OrderedConsumerConfig) (jetstream.Consumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "OrderedConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.Consumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// OrderedConsumer indicates an expected call of OrderedConsumer.
+func (mr *MockStreamMockRecorder) OrderedConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OrderedConsumer", reflect.TypeOf((*MockStream)(nil).OrderedConsumer), arg0, arg1)
+}
+
+// PauseConsumer mocks base method.
+func (m *MockStream) PauseConsumer(arg0 context.Context, arg1 string, arg2 time.Time) (*jetstream.ConsumerPauseResponse, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PauseConsumer", arg0, arg1, arg2)
+ ret0, _ := ret[0].(*jetstream.ConsumerPauseResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PauseConsumer indicates an expected call of PauseConsumer.
+func (mr *MockStreamMockRecorder) PauseConsumer(arg0, arg1, arg2 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PauseConsumer", reflect.TypeOf((*MockStream)(nil).PauseConsumer), arg0, arg1, arg2)
+}
+
+// Purge mocks base method.
+func (m *MockStream) Purge(arg0 context.Context, arg1 ...jetstream.StreamPurgeOpt) error {
+ m.ctrl.T.Helper()
+ varargs := []interface{}{arg0}
+ for _, a := range arg1 {
+ varargs = append(varargs, a)
+ }
+ ret := m.ctrl.Call(m, "Purge", varargs...)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// Purge indicates an expected call of Purge.
+func (mr *MockStreamMockRecorder) Purge(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ varargs := append([]interface{}{arg0}, arg1...)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Purge", reflect.TypeOf((*MockStream)(nil).Purge), varargs...)
+}
+
+// PushConsumer mocks base method.
+func (m *MockStream) PushConsumer(arg0 context.Context, arg1 string) (jetstream.PushConsumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PushConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.PushConsumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PushConsumer indicates an expected call of PushConsumer.
+func (mr *MockStreamMockRecorder) PushConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PushConsumer", reflect.TypeOf((*MockStream)(nil).PushConsumer), arg0, arg1)
+}
+
+// ResumeConsumer mocks base method.
+func (m *MockStream) ResumeConsumer(arg0 context.Context, arg1 string) (*jetstream.ConsumerPauseResponse, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ResumeConsumer", arg0, arg1)
+ ret0, _ := ret[0].(*jetstream.ConsumerPauseResponse)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ResumeConsumer indicates an expected call of ResumeConsumer.
+func (mr *MockStreamMockRecorder) ResumeConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResumeConsumer", reflect.TypeOf((*MockStream)(nil).ResumeConsumer), arg0, arg1)
+}
+
+// SecureDeleteMsg mocks base method.
+func (m *MockStream) SecureDeleteMsg(arg0 context.Context, arg1 uint64) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SecureDeleteMsg", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// SecureDeleteMsg indicates an expected call of SecureDeleteMsg.
+func (mr *MockStreamMockRecorder) SecureDeleteMsg(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SecureDeleteMsg", reflect.TypeOf((*MockStream)(nil).SecureDeleteMsg), arg0, arg1)
+}
+
+// UnpinConsumer mocks base method.
+func (m *MockStream) UnpinConsumer(arg0 context.Context, arg1, arg2 string) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UnpinConsumer", arg0, arg1, arg2)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UnpinConsumer indicates an expected call of UnpinConsumer.
+func (mr *MockStreamMockRecorder) UnpinConsumer(arg0, arg1, arg2 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnpinConsumer", reflect.TypeOf((*MockStream)(nil).UnpinConsumer), arg0, arg1, arg2)
+}
+
+// UpdateConsumer mocks base method.
+func (m *MockStream) UpdateConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.Consumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.Consumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdateConsumer indicates an expected call of UpdateConsumer.
+func (mr *MockStreamMockRecorder) UpdateConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateConsumer", reflect.TypeOf((*MockStream)(nil).UpdateConsumer), arg0, arg1)
+}
+
+// UpdatePushConsumer mocks base method.
+func (m *MockStream) UpdatePushConsumer(arg0 context.Context, arg1 jetstream.ConsumerConfig) (jetstream.PushConsumer, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdatePushConsumer", arg0, arg1)
+ ret0, _ := ret[0].(jetstream.PushConsumer)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UpdatePushConsumer indicates an expected call of UpdatePushConsumer.
+func (mr *MockStreamMockRecorder) UpdatePushConsumer(arg0, arg1 interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePushConsumer", reflect.TypeOf((*MockStream)(nil).UpdatePushConsumer), arg0, arg1)
+}
diff --git a/internal/audit/stream_store.go b/internal/audit/stream_store.go
new file mode 100644
index 000000000..6af048e0f
--- /dev/null
+++ b/internal/audit/stream_store.go
@@ -0,0 +1,244 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package audit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "time"
+
+ "github.com/nats-io/nats.go/jetstream"
+)
+
+// ensure StreamStore implements Store at compile time.
+var _ Store = (*StreamStore)(nil)
+
+// marshalJSON is a package-level variable for testing the marshal error path.
+var marshalJSON = json.Marshal
+
+// fetchTimeout is the maximum wait time for fetching messages from a consumer.
+const fetchTimeout = 5 * time.Second
+
+// Publisher defines the interface for publishing messages to NATS subjects.
+type Publisher interface {
+ Publish(
+ ctx context.Context,
+ subject string,
+ data []byte,
+ ) error
+}
+
+// StreamStore implements Store backed by a NATS JetStream stream.
+type StreamStore struct {
+ logger *slog.Logger
+ stream jetstream.Stream
+ publisher Publisher
+ subject string
+}
+
+// NewStreamStore creates a new StreamStore.
+func NewStreamStore(
+ logger *slog.Logger,
+ stream jetstream.Stream,
+ publisher Publisher,
+ subject string,
+) *StreamStore {
+ return &StreamStore{
+ logger: logger.With(slog.String("subsystem", "audit")),
+ stream: stream,
+ publisher: publisher,
+ subject: subject,
+ }
+}
+
+// Write persists an audit entry by publishing to the stream.
+func (s *StreamStore) Write(
+ ctx context.Context,
+ entry Entry,
+) error {
+ data, err := marshalJSON(entry)
+ if err != nil {
+ return fmt.Errorf("marshal audit entry: %w", err)
+ }
+
+ subject := s.subject + "." + entry.ID
+ if err := s.publisher.Publish(ctx, subject, data); err != nil {
+ return fmt.Errorf("publish audit entry: %w", err)
+ }
+
+ return nil
+}
+
+// Get retrieves a single audit entry by ID.
+func (s *StreamStore) Get(
+ ctx context.Context,
+ id string,
+) (*Entry, error) {
+ subject := s.subject + "." + id
+
+ msg, err := s.stream.GetLastMsgForSubject(ctx, subject)
+ if err != nil {
+ return nil, fmt.Errorf("get audit entry: not found: %w", err)
+ }
+
+ var entry Entry
+ if err := json.Unmarshal(msg.Data, &entry); err != nil {
+ return nil, fmt.Errorf("unmarshal audit entry: %w", err)
+ }
+
+ return &entry, nil
+}
+
+// List retrieves audit entries with pagination, newest-first.
+// Returns the entries, total count, and any error.
+func (s *StreamStore) List(
+ ctx context.Context,
+ limit int,
+ offset int,
+) ([]Entry, int, error) {
+ info, err := s.stream.Info(ctx)
+ if err != nil {
+ return nil, 0, fmt.Errorf("get stream info: %w", err)
+ }
+
+ total := int(info.State.Msgs)
+ if total == 0 {
+ return []Entry{}, 0, nil
+ }
+
+ if offset >= total {
+ return []Entry{}, total, nil
+ }
+
+ // For newest-first pagination, we need to compute which sequence
+ // range to fetch. Messages are stored oldest=FirstSeq to
+ // newest=FirstSeq+Msgs-1. To get the N-th newest page:
+ // newestSeq = FirstSeq + Msgs - 1
+ // startSeq = newestSeq - offset - limit + 1
+ // We fetch `limit` messages starting at startSeq, then reverse.
+ newestSeq := info.State.FirstSeq + info.State.Msgs - 1
+ count := limit
+ if offset+count > total {
+ count = total - offset
+ }
+
+ startSeq := newestSeq - uint64(offset) - uint64(count) + 1
+
+ consumer, err := s.stream.OrderedConsumer(ctx, jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverByStartSequencePolicy,
+ OptStartSeq: startSeq,
+ })
+ if err != nil {
+ return nil, 0, fmt.Errorf("create ordered consumer: %w", err)
+ }
+
+ batch, err := consumer.Fetch(count, jetstream.FetchMaxWait(fetchTimeout))
+ if err != nil {
+ return nil, 0, fmt.Errorf("fetch audit entries: %w", err)
+ }
+
+ entries := make([]Entry, 0, count)
+ for msg := range batch.Messages() {
+ var entry Entry
+ if err := json.Unmarshal(msg.Data(), &entry); err != nil {
+ s.logger.Warn(
+ "failed to unmarshal audit entry",
+ slog.String("error", err.Error()),
+ )
+
+ continue
+ }
+
+ entries = append(entries, entry)
+ }
+
+ if err := batch.Error(); err != nil {
+ s.logger.Warn(
+ "batch error during list",
+ slog.String("error", err.Error()),
+ )
+ }
+
+ // Reverse for newest-first ordering.
+ for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
+ entries[i], entries[j] = entries[j], entries[i]
+ }
+
+ return entries, total, nil
+}
+
+// ListAll retrieves all audit entries without pagination, newest-first.
+func (s *StreamStore) ListAll(
+ ctx context.Context,
+) ([]Entry, error) {
+ info, err := s.stream.Info(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get stream info: %w", err)
+ }
+
+ total := int(info.State.Msgs)
+ if total == 0 {
+ return []Entry{}, nil
+ }
+
+ consumer, err := s.stream.OrderedConsumer(ctx, jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverAllPolicy,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create ordered consumer: %w", err)
+ }
+
+ batch, err := consumer.Fetch(total, jetstream.FetchMaxWait(fetchTimeout))
+ if err != nil {
+ return nil, fmt.Errorf("fetch audit entries: %w", err)
+ }
+
+ entries := make([]Entry, 0, total)
+ for msg := range batch.Messages() {
+ var entry Entry
+ if err := json.Unmarshal(msg.Data(), &entry); err != nil {
+ s.logger.Warn(
+ "failed to unmarshal audit entry",
+ slog.String("error", err.Error()),
+ )
+
+ continue
+ }
+
+ entries = append(entries, entry)
+ }
+
+ if err := batch.Error(); err != nil {
+ s.logger.Warn(
+ "batch error during list all",
+ slog.String("error", err.Error()),
+ )
+ }
+
+ // Reverse for newest-first ordering.
+ for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 {
+ entries[i], entries[j] = entries[j], entries[i]
+ }
+
+ return entries, nil
+}
diff --git a/internal/audit/stream_store_public_test.go b/internal/audit/stream_store_public_test.go
new file mode 100644
index 000000000..79ad71f25
--- /dev/null
+++ b/internal/audit/stream_store_public_test.go
@@ -0,0 +1,673 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package audit_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/nats-io/nats.go/jetstream"
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/internal/audit"
+ "github.com/retr0h/osapi/internal/audit/mocks"
+)
+
+type StreamStorePublicTestSuite struct {
+ suite.Suite
+
+ ctrl *gomock.Controller
+ mockStream *mocks.MockStream
+ mockPublisher *mocks.MockPublisher
+ store *audit.StreamStore
+}
+
+func (s *StreamStorePublicTestSuite) SetupTest() {
+ s.ctrl = gomock.NewController(s.T())
+ s.mockStream = mocks.NewMockStream(s.ctrl)
+ s.mockPublisher = mocks.NewMockPublisher(s.ctrl)
+ s.store = audit.NewStreamStore(
+ slog.Default(),
+ s.mockStream,
+ s.mockPublisher,
+ "audit.log",
+ )
+}
+
+func (s *StreamStorePublicTestSuite) TearDownTest() {
+ s.ctrl.Finish()
+}
+
+func (s *StreamStorePublicTestSuite) TearDownSubTest() {
+ audit.ResetMarshalJSON()
+}
+
+func (s *StreamStorePublicTestSuite) newEntry(
+ id string,
+) audit.Entry {
+ return audit.Entry{
+ ID: id,
+ Timestamp: time.Now(),
+ User: "user@example.com",
+ Roles: []string{"admin"},
+ Method: "GET",
+ Path: "/node/hostname",
+ SourceIP: "127.0.0.1",
+ ResponseCode: 200,
+ DurationMs: 42,
+ TraceID: "abc123",
+ }
+}
+
+func (s *StreamStorePublicTestSuite) newMsgChan(
+ msgs ...jetstream.Msg,
+) <-chan jetstream.Msg {
+ ch := make(chan jetstream.Msg, len(msgs))
+ for _, msg := range msgs {
+ ch <- msg
+ }
+ close(ch)
+
+ return ch
+}
+
+func (s *StreamStorePublicTestSuite) newStreamInfo(
+ msgs uint64,
+ firstSeq uint64,
+) *jetstream.StreamInfo {
+ return &jetstream.StreamInfo{
+ State: jetstream.StreamState{
+ Msgs: msgs,
+ FirstSeq: firstSeq,
+ },
+ }
+}
+
+func (s *StreamStorePublicTestSuite) TestWrite() {
+ tests := []struct {
+ name string
+ entry audit.Entry
+ setupMock func()
+ validateFunc func(error)
+ }{
+ {
+ name: "successfully publishes entry",
+ entry: s.newEntry("entry-1"),
+ setupMock: func() {
+ s.mockPublisher.EXPECT().
+ Publish(gomock.Any(), "audit.log.entry-1", gomock.Any()).
+ Return(nil)
+ },
+ validateFunc: func(err error) {
+ s.NoError(err)
+ },
+ },
+ {
+ name: "returns error when publish fails",
+ entry: s.newEntry("entry-2"),
+ setupMock: func() {
+ s.mockPublisher.EXPECT().
+ Publish(gomock.Any(), "audit.log.entry-2", gomock.Any()).
+ Return(fmt.Errorf("publish error"))
+ },
+ validateFunc: func(err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "publish audit entry")
+ },
+ },
+ {
+ name: "returns error when marshal fails",
+ entry: s.newEntry("entry-3"),
+ setupMock: func() {
+ audit.SetMarshalJSON(func(_ interface{}) ([]byte, error) {
+ return nil, fmt.Errorf("marshal failure")
+ })
+ },
+ validateFunc: func(err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "marshal audit entry")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+ err := s.store.Write(context.Background(), tt.entry)
+ tt.validateFunc(err)
+ })
+ }
+}
+
+func (s *StreamStorePublicTestSuite) TestGet() {
+ entry := s.newEntry("entry-1")
+ data, _ := json.Marshal(entry)
+
+ tests := []struct {
+ name string
+ id string
+ setupMock func()
+ validateFunc func(*audit.Entry, error)
+ }{
+ {
+ name: "successfully gets entry",
+ id: "entry-1",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ GetLastMsgForSubject(gomock.Any(), "audit.log.entry-1").
+ Return(&jetstream.RawStreamMsg{Data: data}, nil)
+ },
+ validateFunc: func(e *audit.Entry, err error) {
+ s.NoError(err)
+ s.Require().NotNil(e)
+ s.Equal("entry-1", e.ID)
+ s.Equal("user@example.com", e.User)
+ s.Equal("abc123", e.TraceID)
+ },
+ },
+ {
+ name: "returns error with not found when subject not found",
+ id: "missing",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ GetLastMsgForSubject(gomock.Any(), "audit.log.missing").
+ Return(nil, jetstream.ErrMsgNotFound)
+ },
+ validateFunc: func(e *audit.Entry, err error) {
+ s.Error(err)
+ s.Nil(e)
+ s.Contains(err.Error(), "not found")
+ },
+ },
+ {
+ name: "returns error when unmarshal fails",
+ id: "bad-json",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ GetLastMsgForSubject(gomock.Any(), "audit.log.bad-json").
+ Return(&jetstream.RawStreamMsg{Data: []byte("not-json")}, nil)
+ },
+ validateFunc: func(e *audit.Entry, err error) {
+ s.Error(err)
+ s.Nil(e)
+ s.Contains(err.Error(), "unmarshal audit entry")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+ result, err := s.store.Get(context.Background(), tt.id)
+ tt.validateFunc(result, err)
+ })
+ }
+}
+
+func (s *StreamStorePublicTestSuite) TestList() {
+ entry1 := s.newEntry("aaa")
+ entry2 := s.newEntry("bbb")
+ entry3 := s.newEntry("ccc")
+ data1, _ := json.Marshal(entry1)
+ data2, _ := json.Marshal(entry2)
+ data3, _ := json.Marshal(entry3)
+
+ tests := []struct {
+ name string
+ limit int
+ offset int
+ setupMock func()
+ validateFunc func([]audit.Entry, int, error)
+ }{
+ {
+ name: "returns entries newest-first within limit",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverByStartSequencePolicy,
+ OptStartSeq: 1,
+ }).
+ Return(mockConsumer, nil)
+
+ msg1 := mocks.NewMockMsg(s.ctrl)
+ msg1.EXPECT().Data().Return(data1)
+ msg2 := mocks.NewMockMsg(s.ctrl)
+ msg2.EXPECT().Data().Return(data2)
+ msg3 := mocks.NewMockMsg(s.ctrl)
+ msg3.EXPECT().Data().Return(data3)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(msg1, msg2, msg3))
+ mockBatch.EXPECT().Error().Return(nil)
+
+ mockConsumer.EXPECT().
+ Fetch(3, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(3, total)
+ s.Len(entries, 3)
+ // Reversed: newest first
+ s.Equal("ccc", entries[0].ID)
+ s.Equal("bbb", entries[1].ID)
+ s.Equal("aaa", entries[2].ID)
+ },
+ },
+ {
+ name: "applies pagination offset and limit",
+ limit: 1,
+ offset: 1,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ // newestSeq=3, offset=1, count=1 => startSeq=3-1-1+1=2
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverByStartSequencePolicy,
+ OptStartSeq: 2,
+ }).
+ Return(mockConsumer, nil)
+
+ msg := mocks.NewMockMsg(s.ctrl)
+ msg.EXPECT().Data().Return(data2)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(msg))
+ mockBatch.EXPECT().Error().Return(nil)
+
+ mockConsumer.EXPECT().
+ Fetch(1, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(3, total)
+ s.Len(entries, 1)
+ s.Equal("bbb", entries[0].ID)
+ },
+ },
+ {
+ name: "returns empty when offset exceeds total",
+ limit: 10,
+ offset: 100,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(3, total)
+ s.Empty(entries)
+ },
+ },
+ {
+ name: "returns empty for empty stream",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(0, 0), nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(0, total)
+ s.Empty(entries)
+ },
+ },
+ {
+ name: "returns error when Info fails",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(nil, fmt.Errorf("connection error"))
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Equal(0, total)
+ s.Contains(err.Error(), "get stream info")
+ },
+ },
+ {
+ name: "returns error when OrderedConsumer fails",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(nil, fmt.Errorf("consumer error"))
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Equal(0, total)
+ s.Contains(err.Error(), "create ordered consumer")
+ },
+ },
+ {
+ name: "returns error when Fetch fails",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+ mockConsumer.EXPECT().
+ Fetch(3, gomock.Any()).
+ Return(nil, fmt.Errorf("fetch error"))
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Equal(0, total)
+ s.Contains(err.Error(), "fetch audit entries")
+ },
+ },
+ {
+ name: "skips entries when unmarshal fails",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(2, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+
+ badMsg := mocks.NewMockMsg(s.ctrl)
+ badMsg.EXPECT().Data().Return([]byte("not-json"))
+ goodMsg := mocks.NewMockMsg(s.ctrl)
+ goodMsg.EXPECT().Data().Return(data1)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(badMsg, goodMsg))
+ mockBatch.EXPECT().Error().Return(nil)
+
+ mockConsumer.EXPECT().
+ Fetch(2, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(2, total)
+ s.Len(entries, 1)
+ s.Equal("aaa", entries[0].ID)
+ },
+ },
+ {
+ name: "logs warning on batch error",
+ limit: 10,
+ offset: 0,
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(1, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+
+ msg := mocks.NewMockMsg(s.ctrl)
+ msg.EXPECT().Data().Return(data1)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(msg))
+ mockBatch.EXPECT().Error().Return(fmt.Errorf("batch timeout"))
+
+ mockConsumer.EXPECT().
+ Fetch(1, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, total int, err error) {
+ s.NoError(err)
+ s.Equal(1, total)
+ s.Len(entries, 1)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+ entries, total, err := s.store.List(context.Background(), tt.limit, tt.offset)
+ tt.validateFunc(entries, total, err)
+ })
+ }
+}
+
+func (s *StreamStorePublicTestSuite) TestListAll() {
+ entry1 := s.newEntry("aaa")
+ entry2 := s.newEntry("bbb")
+ entry3 := s.newEntry("ccc")
+ data1, _ := json.Marshal(entry1)
+ data2, _ := json.Marshal(entry2)
+ data3, _ := json.Marshal(entry3)
+
+ tests := []struct {
+ name string
+ setupMock func()
+ validateFunc func([]audit.Entry, error)
+ }{
+ {
+ name: "returns all entries newest-first",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), jetstream.OrderedConsumerConfig{
+ DeliverPolicy: jetstream.DeliverAllPolicy,
+ }).
+ Return(mockConsumer, nil)
+
+ msg1 := mocks.NewMockMsg(s.ctrl)
+ msg1.EXPECT().Data().Return(data1)
+ msg2 := mocks.NewMockMsg(s.ctrl)
+ msg2.EXPECT().Data().Return(data2)
+ msg3 := mocks.NewMockMsg(s.ctrl)
+ msg3.EXPECT().Data().Return(data3)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(msg1, msg2, msg3))
+ mockBatch.EXPECT().Error().Return(nil)
+
+ mockConsumer.EXPECT().
+ Fetch(3, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.NoError(err)
+ s.Len(entries, 3)
+ // Reversed: newest first
+ s.Equal("ccc", entries[0].ID)
+ s.Equal("bbb", entries[1].ID)
+ s.Equal("aaa", entries[2].ID)
+ },
+ },
+ {
+ name: "returns empty for empty stream",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(0, 0), nil)
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.NoError(err)
+ s.Empty(entries)
+ },
+ },
+ {
+ name: "returns error when Info fails",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(nil, fmt.Errorf("connection error"))
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Contains(err.Error(), "get stream info")
+ },
+ },
+ {
+ name: "returns error when OrderedConsumer fails",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(nil, fmt.Errorf("consumer error"))
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Contains(err.Error(), "create ordered consumer")
+ },
+ },
+ {
+ name: "returns error when Fetch fails",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(3, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+ mockConsumer.EXPECT().
+ Fetch(3, gomock.Any()).
+ Return(nil, fmt.Errorf("fetch error"))
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.Error(err)
+ s.Nil(entries)
+ s.Contains(err.Error(), "fetch audit entries")
+ },
+ },
+ {
+ name: "skips entries when unmarshal fails",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(2, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+
+ badMsg := mocks.NewMockMsg(s.ctrl)
+ badMsg.EXPECT().Data().Return([]byte("not-json"))
+ goodMsg := mocks.NewMockMsg(s.ctrl)
+ goodMsg.EXPECT().Data().Return(data1)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(badMsg, goodMsg))
+ mockBatch.EXPECT().Error().Return(nil)
+
+ mockConsumer.EXPECT().
+ Fetch(2, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.NoError(err)
+ s.Len(entries, 1)
+ s.Equal("aaa", entries[0].ID)
+ },
+ },
+ {
+ name: "logs warning on batch error",
+ setupMock: func() {
+ s.mockStream.EXPECT().
+ Info(gomock.Any()).
+ Return(s.newStreamInfo(1, 1), nil)
+
+ mockConsumer := mocks.NewMockConsumer(s.ctrl)
+ s.mockStream.EXPECT().
+ OrderedConsumer(gomock.Any(), gomock.Any()).
+ Return(mockConsumer, nil)
+
+ msg := mocks.NewMockMsg(s.ctrl)
+ msg.EXPECT().Data().Return(data1)
+
+ mockBatch := mocks.NewMockMessageBatch(s.ctrl)
+ mockBatch.EXPECT().Messages().Return(s.newMsgChan(msg))
+ mockBatch.EXPECT().Error().Return(fmt.Errorf("batch timeout"))
+
+ mockConsumer.EXPECT().
+ Fetch(1, gomock.Any()).
+ Return(mockBatch, nil)
+ },
+ validateFunc: func(entries []audit.Entry, err error) {
+ s.NoError(err)
+ s.Len(entries, 1)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tt.setupMock()
+ entries, err := s.store.ListAll(context.Background())
+ tt.validateFunc(entries, err)
+ })
+ }
+}
+
+func TestStreamStorePublicTestSuite(t *testing.T) {
+ suite.Run(t, new(StreamStorePublicTestSuite))
+}
diff --git a/internal/audit/types.go b/internal/audit/types.go
index 6dffc3c64..69cf70bd5 100644
--- a/internal/audit/types.go
+++ b/internal/audit/types.go
@@ -45,4 +45,6 @@ type Entry struct {
ResponseCode int `json:"response_code"`
// DurationMs is the request processing time in milliseconds.
DurationMs int64 `json:"duration_ms"`
+ // TraceID is the OpenTelemetry trace ID for correlation.
+ TraceID string `json:"trace_id,omitempty"`
}
diff --git a/internal/cli/nats.go b/internal/cli/nats.go
index 3581778cb..8c26168aa 100644
--- a/internal/cli/nats.go
+++ b/internal/cli/nats.go
@@ -124,20 +124,30 @@ func BuildStateKVConfig(
}
}
-// BuildAuditKVConfig builds a jetstream.KeyValueConfig from audit config values.
-func BuildAuditKVConfig(
+// BuildAuditStreamConfig builds a jetstream.StreamConfig from audit
+// config values.
+func BuildAuditStreamConfig(
namespace string,
auditCfg config.NATSAudit,
-) jetstream.KeyValueConfig {
- auditBucket := job.ApplyNamespaceToInfraName(namespace, auditCfg.Bucket)
- auditTTL, _ := time.ParseDuration(auditCfg.TTL)
-
- return jetstream.KeyValueConfig{
- Bucket: auditBucket,
- TTL: auditTTL,
+) jetstream.StreamConfig {
+ streamName := job.ApplyNamespaceToInfraName(
+ namespace,
+ auditCfg.Stream,
+ )
+ subject := job.ApplyNamespaceToSubjects(
+ namespace,
+ auditCfg.Subject,
+ )
+ maxAge, _ := time.ParseDuration(auditCfg.MaxAge)
+
+ return jetstream.StreamConfig{
+ Name: streamName,
+ Subjects: []string{subject + ".>"},
+ MaxAge: maxAge,
MaxBytes: auditCfg.MaxBytes,
Storage: ParseJetstreamStorageType(auditCfg.Storage),
Replicas: auditCfg.Replicas,
+ Discard: jetstream.DiscardOld,
}
}
diff --git a/internal/cli/nats_public_test.go b/internal/cli/nats_public_test.go
index 81c268928..d8862016f 100644
--- a/internal/cli/nats_public_test.go
+++ b/internal/cli/nats_public_test.go
@@ -321,68 +321,75 @@ func (suite *NATSPublicTestSuite) TestBuildStateKVConfig() {
}
}
-func (suite *NATSPublicTestSuite) TestBuildAuditKVConfig() {
+func (suite *NATSPublicTestSuite) TestBuildAuditStreamConfig() {
tests := []struct {
name string
namespace string
auditCfg config.NATSAudit
- validateFn func(jetstream.KeyValueConfig)
+ validateFn func(jetstream.StreamConfig)
}{
{
name: "when namespace is set",
namespace: "osapi",
auditCfg: config.NATSAudit{
- Bucket: "audit-log",
- TTL: "720h",
+ Stream: "AUDIT",
+ Subject: "audit",
+ MaxAge: "720h",
MaxBytes: 52428800,
Storage: "file",
Replicas: 1,
},
- validateFn: func(cfg jetstream.KeyValueConfig) {
- assert.Equal(suite.T(), "osapi-audit-log", cfg.Bucket)
- assert.Equal(suite.T(), 720*time.Hour, cfg.TTL)
+ validateFn: func(cfg jetstream.StreamConfig) {
+ assert.Equal(suite.T(), "osapi-AUDIT", cfg.Name)
+ assert.Equal(suite.T(), []string{"osapi.audit.>"}, cfg.Subjects)
+ assert.Equal(suite.T(), 720*time.Hour, cfg.MaxAge)
assert.Equal(suite.T(), int64(52428800), cfg.MaxBytes)
assert.Equal(suite.T(), jetstream.FileStorage, cfg.Storage)
assert.Equal(suite.T(), 1, cfg.Replicas)
+ assert.Equal(suite.T(), jetstream.DiscardOld, cfg.Discard)
},
},
{
name: "when namespace is empty",
namespace: "",
auditCfg: config.NATSAudit{
- Bucket: "audit-log",
- TTL: "24h",
+ Stream: "AUDIT",
+ Subject: "audit",
+ MaxAge: "24h",
MaxBytes: 1048576,
Storage: "memory",
Replicas: 3,
},
- validateFn: func(cfg jetstream.KeyValueConfig) {
- assert.Equal(suite.T(), "audit-log", cfg.Bucket)
- assert.Equal(suite.T(), 24*time.Hour, cfg.TTL)
+ validateFn: func(cfg jetstream.StreamConfig) {
+ assert.Equal(suite.T(), "AUDIT", cfg.Name)
+ assert.Equal(suite.T(), []string{"audit.>"}, cfg.Subjects)
+ assert.Equal(suite.T(), 24*time.Hour, cfg.MaxAge)
assert.Equal(suite.T(), int64(1048576), cfg.MaxBytes)
assert.Equal(suite.T(), jetstream.MemoryStorage, cfg.Storage)
assert.Equal(suite.T(), 3, cfg.Replicas)
+ assert.Equal(suite.T(), jetstream.DiscardOld, cfg.Discard)
},
},
{
- name: "when TTL is invalid defaults to zero",
+ name: "when MaxAge is invalid defaults to zero",
namespace: "",
auditCfg: config.NATSAudit{
- Bucket: "audit-log",
- TTL: "invalid",
+ Stream: "AUDIT",
+ Subject: "audit",
+ MaxAge: "invalid",
MaxBytes: 0,
Storage: "file",
Replicas: 1,
},
- validateFn: func(cfg jetstream.KeyValueConfig) {
- assert.Equal(suite.T(), time.Duration(0), cfg.TTL)
+ validateFn: func(cfg jetstream.StreamConfig) {
+ assert.Equal(suite.T(), time.Duration(0), cfg.MaxAge)
},
},
}
for _, tc := range tests {
suite.Run(tc.name, func() {
- got := cli.BuildAuditKVConfig(tc.namespace, tc.auditCfg)
+ got := cli.BuildAuditStreamConfig(tc.namespace, tc.auditCfg)
tc.validateFn(got)
})
diff --git a/internal/config/nats.go b/internal/config/nats.go
index 83e81a5fe..36f2e0f73 100644
--- a/internal/config/nats.go
+++ b/internal/config/nats.go
@@ -28,7 +28,6 @@ func (n NATS) AllKVBuckets() []KVBucketInfo {
return []KVBucketInfo{
{Name: "job-queue", Bucket: n.KV.Bucket},
{Name: "job-responses", Bucket: n.KV.ResponseBucket},
- {Name: "audit", Bucket: n.Audit.Bucket},
{Name: "registry", Bucket: n.Registry.Bucket},
{Name: "facts", Bucket: n.Facts.Bucket},
{Name: "state", Bucket: n.State.Bucket},
diff --git a/internal/config/nats_public_test.go b/internal/config/nats_public_test.go
index cbbefac81..cc703753e 100644
--- a/internal/config/nats_public_test.go
+++ b/internal/config/nats_public_test.go
@@ -46,7 +46,6 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
Bucket: "job-queue",
ResponseBucket: "job-responses",
},
- Audit: config.NATSAudit{Bucket: "audit-log"},
Registry: config.NATSRegistry{Bucket: "agent-registry"},
Facts: config.NATSFacts{Bucket: "agent-facts"},
State: config.NATSState{Bucket: "agent-state"},
@@ -55,7 +54,6 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
expectedNames: []string{
"job-queue",
"job-responses",
- "audit",
"registry",
"facts",
"state",
@@ -64,7 +62,6 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
expectedBuckets: []string{
"job-queue",
"job-responses",
- "audit-log",
"agent-registry",
"agent-facts",
"agent-state",
@@ -77,13 +74,12 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
expectedNames: []string{
"job-queue",
"job-responses",
- "audit",
"registry",
"facts",
"state",
"file-state",
},
- expectedBuckets: []string{"", "", "", "", "", "", ""},
+ expectedBuckets: []string{"", "", "", "", "", ""},
},
{
name: "partial config — only KV buckets set",
@@ -96,7 +92,6 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
expectedNames: []string{
"job-queue",
"job-responses",
- "audit",
"registry",
"facts",
"state",
@@ -109,7 +104,6 @@ func (s *NATSPublicTestSuite) TestAllKVBuckets() {
"",
"",
"",
- "",
},
},
}
diff --git a/internal/config/types.go b/internal/config/types.go
index d4bcc9cb6..654ecfd00 100644
--- a/internal/config/types.go
+++ b/internal/config/types.go
@@ -121,11 +121,13 @@ type NATS struct {
FileState NATSFileState `mapstructure:"file_state,omitempty"`
}
-// NATSAudit configuration for the audit log KV bucket.
+// NATSAudit configuration for the audit log stream.
type NATSAudit struct {
- // Bucket is the KV bucket name for audit log entries.
- Bucket string `mapstructure:"bucket"`
- TTL string `mapstructure:"ttl"` // e.g. "720h" (30 days)
+ // Stream is the JetStream stream name for audit log entries.
+ Stream string `mapstructure:"stream"`
+ // Subject is the base subject prefix for audit messages.
+ Subject string `mapstructure:"subject"`
+ MaxAge string `mapstructure:"max_age"` // e.g. "720h" (30 days)
MaxBytes int64 `mapstructure:"max_bytes"`
Storage string `mapstructure:"storage"` // "file" or "memory"
Replicas int `mapstructure:"replicas"`
diff --git a/internal/controller/api/audit/audit_list.go b/internal/controller/api/audit/audit_list.go
index b8057efbf..7520ef3e7 100644
--- a/internal/controller/api/audit/audit_list.go
+++ b/internal/controller/api/audit/audit_list.go
@@ -90,5 +90,8 @@ func mapEntryToGen(
if e.OperationID != "" {
entry.OperationId = &e.OperationID
}
+ if e.TraceID != "" {
+ entry.TraceId = &e.TraceID
+ }
return entry
}
diff --git a/internal/controller/api/audit/audit_list_public_test.go b/internal/controller/api/audit/audit_list_public_test.go
index 25873f162..c81b57cb8 100644
--- a/internal/controller/api/audit/audit_list_public_test.go
+++ b/internal/controller/api/audit/audit_list_public_test.go
@@ -104,6 +104,63 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogs() {
s.Equal("user@example.com", r.Items[0].User)
},
},
+ {
+ name: "returns entry with trace ID",
+ params: gen.GetAuditLogsParams{Limit: &limit, Offset: &offset},
+ setupStore: func() {
+ s.mockStore.EXPECT().
+ List(gomock.Any(), limit, offset).
+ Return([]auditstore.Entry{
+ {
+ ID: "550e8400-e29b-41d4-a716-446655440000",
+ Timestamp: time.Now(),
+ User: "user@example.com",
+ Roles: []string{"admin"},
+ Method: "GET",
+ Path: "/node/hostname",
+ SourceIP: "127.0.0.1",
+ ResponseCode: 200,
+ DurationMs: 42,
+ TraceID: "4bf92f3577b34da6a3ce929d0e0e4736",
+ },
+ }, 1, nil)
+ },
+ validateFunc: func(resp gen.GetAuditLogsResponseObject) {
+ r, ok := resp.(gen.GetAuditLogs200JSONResponse)
+ s.True(ok)
+ s.Len(r.Items, 1)
+ s.Require().NotNil(r.Items[0].TraceId)
+ s.Equal("4bf92f3577b34da6a3ce929d0e0e4736", *r.Items[0].TraceId)
+ },
+ },
+ {
+ name: "returns entry with empty trace ID as nil",
+ params: gen.GetAuditLogsParams{Limit: &limit, Offset: &offset},
+ setupStore: func() {
+ s.mockStore.EXPECT().
+ List(gomock.Any(), limit, offset).
+ Return([]auditstore.Entry{
+ {
+ ID: "550e8400-e29b-41d4-a716-446655440000",
+ Timestamp: time.Now(),
+ User: "user@example.com",
+ Roles: []string{"admin"},
+ Method: "GET",
+ Path: "/node/hostname",
+ SourceIP: "127.0.0.1",
+ ResponseCode: 200,
+ DurationMs: 42,
+ TraceID: "",
+ },
+ }, 1, nil)
+ },
+ validateFunc: func(resp gen.GetAuditLogsResponseObject) {
+ r, ok := resp.(gen.GetAuditLogs200JSONResponse)
+ s.True(ok)
+ s.Len(r.Items, 1)
+ s.Nil(r.Items[0].TraceId)
+ },
+ },
{
name: "returns entry with operation ID",
params: gen.GetAuditLogsParams{Limit: &limit, Offset: &offset},
@@ -219,8 +276,7 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsValidationHTTP() {
name string
query string
setupStore func(mock *auditmocks.MockStore)
- wantCode int
- wantContains []string
+ validateFunc func(rec *httptest.ResponseRecorder)
}{
{
name: "when valid request returns entries",
@@ -242,8 +298,10 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsValidationHTTP() {
},
}, 1, nil)
},
- wantCode: http.StatusOK,
- wantContains: []string{`"total_items":1`},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusOK, rec.Code)
+ s.Contains(rec.Body.String(), `"total_items":1`)
+ },
},
{
name: "when valid limit and offset params",
@@ -253,36 +311,42 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsValidationHTTP() {
List(gomock.Any(), gomock.Any(), gomock.Any()).
Return([]auditstore.Entry{}, 0, nil)
},
- wantCode: http.StatusOK,
- wantContains: []string{`"total_items":0`},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusOK, rec.Code)
+ s.Contains(rec.Body.String(), `"total_items":0`)
+ },
},
{
- name: "when limit is zero returns 400",
- query: "?limit=0",
- setupStore: func(_ *auditmocks.MockStore) {},
- wantCode: http.StatusBadRequest,
- wantContains: []string{`"error"`},
+ name: "when limit is zero returns 400",
+ query: "?limit=0",
+ setupStore: func(_ *auditmocks.MockStore) {},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusBadRequest, rec.Code)
+ s.Contains(rec.Body.String(), `"error"`)
+ },
},
{
- name: "when limit exceeds maximum returns 400",
- query: "?limit=200",
- setupStore: func(_ *auditmocks.MockStore) {},
- wantCode: http.StatusBadRequest,
- wantContains: []string{`"error"`},
+ name: "when limit exceeds maximum returns 400",
+ query: "?limit=200",
+ setupStore: func(_ *auditmocks.MockStore) {},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusBadRequest, rec.Code)
+ s.Contains(rec.Body.String(), `"error"`)
+ },
},
{
- name: "when offset is negative returns 400",
- query: "?offset=-1",
- setupStore: func(_ *auditmocks.MockStore) {},
- wantCode: http.StatusBadRequest,
- wantContains: []string{},
+ name: "when offset is negative returns 400",
+ query: "?offset=-1",
+ setupStore: func(_ *auditmocks.MockStore) {},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusBadRequest, rec.Code)
+ },
},
}
for _, tc := range tests {
s.Run(tc.name, func() {
ctrl := gomock.NewController(s.T())
- defer ctrl.Finish()
mock := auditmocks.NewMockStore(ctrl)
tc.setupStore(mock)
@@ -302,10 +366,8 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsValidationHTTP() {
a.Echo.ServeHTTP(rec, req)
- s.Equal(tc.wantCode, rec.Code)
- for _, str := range tc.wantContains {
- s.Contains(rec.Body.String(), str)
- }
+ tc.validateFunc(rec)
+ ctrl.Finish()
})
}
}
@@ -319,17 +381,18 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsRBACHTTP() {
name string
setupAuth func(req *http.Request)
setupStore func(mock *auditmocks.MockStore)
- wantCode int
- wantContains []string
+ validateFunc func(rec *httptest.ResponseRecorder)
}{
{
name: "when no token returns 401",
setupAuth: func(_ *http.Request) {
// No auth header set
},
- setupStore: func(_ *auditmocks.MockStore) {},
- wantCode: http.StatusUnauthorized,
- wantContains: []string{"Bearer token required"},
+ setupStore: func(_ *auditmocks.MockStore) {},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusUnauthorized, rec.Code)
+ s.Contains(rec.Body.String(), "Bearer token required")
+ },
},
{
name: "when insufficient permissions returns 403",
@@ -343,9 +406,11 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsRBACHTTP() {
s.Require().NoError(err)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
},
- setupStore: func(_ *auditmocks.MockStore) {},
- wantCode: http.StatusForbidden,
- wantContains: []string{"Insufficient permissions"},
+ setupStore: func(_ *auditmocks.MockStore) {},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusForbidden, rec.Code)
+ s.Contains(rec.Body.String(), "Insufficient permissions")
+ },
},
{
name: "when valid token with audit:read returns 200",
@@ -364,15 +429,16 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsRBACHTTP() {
List(gomock.Any(), gomock.Any(), gomock.Any()).
Return([]auditstore.Entry{}, 0, nil)
},
- wantCode: http.StatusOK,
- wantContains: []string{`"total_items":0`},
+ validateFunc: func(rec *httptest.ResponseRecorder) {
+ s.Equal(http.StatusOK, rec.Code)
+ s.Contains(rec.Body.String(), `"total_items":0`)
+ },
},
}
for _, tc := range tests {
s.Run(tc.name, func() {
ctrl := gomock.NewController(s.T())
- defer ctrl.Finish()
mock := auditmocks.NewMockStore(ctrl)
tc.setupStore(mock)
@@ -406,10 +472,8 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogsRBACHTTP() {
server.Echo.ServeHTTP(rec, req)
- s.Equal(tc.wantCode, rec.Code)
- for _, str := range tc.wantContains {
- s.Contains(rec.Body.String(), str)
- }
+ tc.validateFunc(rec)
+ ctrl.Finish()
})
}
}
diff --git a/internal/controller/api/audit/gen/api.yaml b/internal/controller/api/audit/gen/api.yaml
index 0df98e7cc..5801d227a 100644
--- a/internal/controller/api/audit/gen/api.yaml
+++ b/internal/controller/api/audit/gen/api.yaml
@@ -235,6 +235,10 @@ components:
format: int64
description: Request duration in milliseconds.
example: 42
+ trace_id:
+ type: string
+ description: OpenTelemetry trace ID for correlation.
+ example: "4bf92f3577b34da6a3ce929d0e0e4736"
required:
- id
- timestamp
diff --git a/internal/controller/api/audit/gen/audit.gen.go b/internal/controller/api/audit/gen/audit.gen.go
index 44c7c56ea..48acdb261 100644
--- a/internal/controller/api/audit/gen/audit.gen.go
+++ b/internal/controller/api/audit/gen/audit.gen.go
@@ -50,6 +50,9 @@ type AuditEntry struct {
// Timestamp When the request was processed.
Timestamp time.Time `json:"timestamp"`
+ // TraceId OpenTelemetry trace ID for correlation.
+ TraceId *string `json:"trace_id,omitempty"`
+
// User Authenticated user (JWT subject).
User string `json:"user"`
}
diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml
index 209f390cb..12a0e68f9 100644
--- a/internal/controller/api/gen/api.yaml
+++ b/internal/controller/api/gen/api.yaml
@@ -5423,6 +5423,10 @@ components:
format: int64
description: Request duration in milliseconds.
example: 42
+ trace_id:
+ type: string
+ description: OpenTelemetry trace ID for correlation.
+ example: 4bf92f3577b34da6a3ce929d0e0e4736
required:
- id
- timestamp
diff --git a/internal/controller/api/middleware_audit.go b/internal/controller/api/middleware_audit.go
index 9adc173fe..aa4840098 100644
--- a/internal/controller/api/middleware_audit.go
+++ b/internal/controller/api/middleware_audit.go
@@ -28,6 +28,7 @@ import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
+ "go.opentelemetry.io/otel/trace"
"github.com/retr0h/osapi/internal/audit"
)
@@ -78,6 +79,13 @@ func auditMiddleware(
DurationMs: time.Since(start).Milliseconds(),
}
+ spanCtx := trace.SpanContextFromContext(
+ c.Request().Context(),
+ )
+ if spanCtx.HasTraceID() {
+ entry.TraceID = spanCtx.TraceID().String()
+ }
+
// Use Background context because this goroutine runs after the HTTP
// response is sent, at which point the request context is canceled.
go func() {
diff --git a/internal/controller/api/middleware_audit_public_test.go b/internal/controller/api/middleware_audit_public_test.go
index 939a59bfd..8c2da375a 100644
--- a/internal/controller/api/middleware_audit_public_test.go
+++ b/internal/controller/api/middleware_audit_public_test.go
@@ -32,6 +32,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/suite"
+ "go.opentelemetry.io/otel/trace"
"github.com/retr0h/osapi/internal/audit"
"github.com/retr0h/osapi/internal/controller/api"
@@ -103,6 +104,7 @@ func (s *AuditMiddlewarePublicTestSuite) TestAuditMiddleware() {
subject string
roles []string
storeErr error
+ setupReq func(req *http.Request) *http.Request
validateFunc func(store *captureStore)
}{
{
@@ -162,6 +164,35 @@ func (s *AuditMiddlewarePublicTestSuite) TestAuditMiddleware() {
s.Empty(entries)
},
},
+ {
+ name: "authenticated request with trace context captures trace ID",
+ path: "/node/hostname",
+ subject: "user@example.com",
+ roles: []string{"admin"},
+ setupReq: func(req *http.Request) *http.Request {
+ traceID, _ := trace.TraceIDFromHex(
+ "4bf92f3577b34da6a3ce929d0e0e4736",
+ )
+ spanCtx := trace.NewSpanContext(trace.SpanContextConfig{
+ TraceID: traceID,
+ SpanID: trace.SpanID{1},
+ TraceFlags: trace.FlagsSampled,
+ })
+ ctx := trace.ContextWithSpanContext(
+ req.Context(), spanCtx,
+ )
+ return req.WithContext(ctx)
+ },
+ validateFunc: func(store *captureStore) {
+ time.Sleep(50 * time.Millisecond)
+ entries := store.getEntries()
+ s.Len(entries, 1)
+ s.Equal(
+ "4bf92f3577b34da6a3ce929d0e0e4736",
+ entries[0].TraceID,
+ )
+ },
+ },
{
name: "store error is handled gracefully",
path: "/node/hostname",
@@ -194,6 +225,9 @@ func (s *AuditMiddlewarePublicTestSuite) TestAuditMiddleware() {
})
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
+ if tt.setupReq != nil {
+ req = tt.setupReq(req)
+ }
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
diff --git a/pkg/sdk/client/audit_types.go b/pkg/sdk/client/audit_types.go
index 8f0848f3e..f0676f5e2 100644
--- a/pkg/sdk/client/audit_types.go
+++ b/pkg/sdk/client/audit_types.go
@@ -38,6 +38,7 @@ type AuditEntry struct {
DurationMs int64 `json:"duration_ms"`
SourceIP string `json:"source_ip"`
OperationID string `json:"operation_id,omitempty"`
+ TraceID string `json:"trace_id,omitempty"`
}
// AuditList is a paginated list of audit entries.
@@ -66,6 +67,10 @@ func auditEntryFromGen(
a.OperationID = *g.OperationId
}
+ if g.TraceId != nil {
+ a.TraceID = *g.TraceId
+ }
+
return a
}
diff --git a/pkg/sdk/client/audit_types_public_test.go b/pkg/sdk/client/audit_types_public_test.go
index 95900316d..6d3bb6754 100644
--- a/pkg/sdk/client/audit_types_public_test.go
+++ b/pkg/sdk/client/audit_types_public_test.go
@@ -35,7 +35,7 @@ type AuditTypesPublicTestSuite struct {
suite.Suite
}
-func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() {
+func (s *AuditTypesPublicTestSuite) TestAuditEntryFromGen() {
now := time.Now().UTC().Truncate(time.Second)
testUUID := openapi_types.UUID{
0x55,
@@ -56,6 +56,7 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() {
0x00,
}
operationID := "getNodeHostname"
+ traceID := "4bf92f3577b34da6a3ce929d0e0e4736"
tests := []struct {
name string
@@ -77,16 +78,55 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() {
OperationId: &operationID,
},
validateFunc: func(a client.AuditEntry) {
- suite.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID)
- suite.Equal(now, a.Timestamp)
- suite.Equal("admin@example.com", a.User)
- suite.Equal([]string{"admin", "write"}, a.Roles)
- suite.Equal("GET", a.Method)
- suite.Equal("/api/v1/node/web-01", a.Path)
- suite.Equal(200, a.ResponseCode)
- suite.Equal(int64(42), a.DurationMs)
- suite.Equal("192.168.1.100", a.SourceIP)
- suite.Equal("getNodeHostname", a.OperationID)
+ s.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID)
+ s.Equal(now, a.Timestamp)
+ s.Equal("admin@example.com", a.User)
+ s.Equal([]string{"admin", "write"}, a.Roles)
+ s.Equal("GET", a.Method)
+ s.Equal("/api/v1/node/web-01", a.Path)
+ s.Equal(200, a.ResponseCode)
+ s.Equal(int64(42), a.DurationMs)
+ s.Equal("192.168.1.100", a.SourceIP)
+ s.Equal("getNodeHostname", a.OperationID)
+ },
+ },
+ {
+ name: "when TraceId is populated",
+ input: gen.AuditEntry{
+ Id: testUUID,
+ Timestamp: now,
+ User: "admin@example.com",
+ Roles: []string{"admin"},
+ Method: "GET",
+ Path: "/api/v1/node/web-01",
+ ResponseCode: 200,
+ DurationMs: 42,
+ SourceIp: "192.168.1.100",
+ TraceId: &traceID,
+ },
+ validateFunc: func(a client.AuditEntry) {
+ s.Equal(
+ "4bf92f3577b34da6a3ce929d0e0e4736",
+ a.TraceID,
+ )
+ },
+ },
+ {
+ name: "when TraceId is nil",
+ input: gen.AuditEntry{
+ Id: testUUID,
+ Timestamp: now,
+ User: "user@example.com",
+ Roles: []string{"read"},
+ Method: "GET",
+ Path: "/api/v1/health",
+ ResponseCode: 200,
+ DurationMs: 5,
+ SourceIp: "10.0.0.1",
+ TraceId: nil,
+ },
+ validateFunc: func(a client.AuditEntry) {
+ s.Empty(a.TraceID)
},
},
{
@@ -104,29 +144,29 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() {
OperationId: nil,
},
validateFunc: func(a client.AuditEntry) {
- suite.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID)
- suite.Equal(now, a.Timestamp)
- suite.Equal("user@example.com", a.User)
- suite.Equal([]string{"read"}, a.Roles)
- suite.Equal("POST", a.Method)
- suite.Equal("/api/v1/jobs", a.Path)
- suite.Equal(201, a.ResponseCode)
- suite.Equal(int64(15), a.DurationMs)
- suite.Equal("10.0.0.1", a.SourceIP)
- suite.Empty(a.OperationID)
+ s.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID)
+ s.Equal(now, a.Timestamp)
+ s.Equal("user@example.com", a.User)
+ s.Equal([]string{"read"}, a.Roles)
+ s.Equal("POST", a.Method)
+ s.Equal("/api/v1/jobs", a.Path)
+ s.Equal(201, a.ResponseCode)
+ s.Equal(int64(15), a.DurationMs)
+ s.Equal("10.0.0.1", a.SourceIP)
+ s.Empty(a.OperationID)
},
},
}
for _, tc := range tests {
- suite.Run(tc.name, func() {
+ s.Run(tc.name, func() {
result := client.ExportAuditEntryFromGen(tc.input)
tc.validateFunc(result)
})
}
}
-func (suite *AuditTypesPublicTestSuite) TestAuditListFromGen() {
+func (s *AuditTypesPublicTestSuite) TestAuditListFromGen() {
now := time.Now().UTC().Truncate(time.Second)
testUUID1 := openapi_types.UUID{
0x55,
@@ -200,12 +240,12 @@ func (suite *AuditTypesPublicTestSuite) TestAuditListFromGen() {
TotalItems: 2,
},
validateFunc: func(al client.AuditList) {
- suite.Equal(2, al.TotalItems)
- suite.Require().Len(al.Items, 2)
- suite.Equal("550e8400-e29b-41d4-a716-446655440001", al.Items[0].ID)
- suite.Equal("admin@example.com", al.Items[0].User)
- suite.Equal("550e8400-e29b-41d4-a716-446655440002", al.Items[1].ID)
- suite.Equal("user@example.com", al.Items[1].User)
+ s.Equal(2, al.TotalItems)
+ s.Require().Len(al.Items, 2)
+ s.Equal("550e8400-e29b-41d4-a716-446655440001", al.Items[0].ID)
+ s.Equal("admin@example.com", al.Items[0].User)
+ s.Equal("550e8400-e29b-41d4-a716-446655440002", al.Items[1].ID)
+ s.Equal("user@example.com", al.Items[1].User)
},
},
{
@@ -215,14 +255,14 @@ func (suite *AuditTypesPublicTestSuite) TestAuditListFromGen() {
TotalItems: 0,
},
validateFunc: func(al client.AuditList) {
- suite.Equal(0, al.TotalItems)
- suite.Empty(al.Items)
+ s.Equal(0, al.TotalItems)
+ s.Empty(al.Items)
},
},
}
for _, tc := range tests {
- suite.Run(tc.name, func() {
+ s.Run(tc.name, func() {
result := client.ExportAuditListFromGen(tc.input)
tc.validateFunc(result)
})
diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go
index d5c42e699..808c3a252 100644
--- a/pkg/sdk/client/gen/client.gen.go
+++ b/pkg/sdk/client/gen/client.gen.go
@@ -583,6 +583,9 @@ type AuditEntry struct {
// Timestamp When the request was processed.
Timestamp time.Time `json:"timestamp"`
+ // TraceId OpenTelemetry trace ID for correlation.
+ TraceId *string `json:"trace_id,omitempty"`
+
// User Authenticated user (JWT subject).
User string `json:"user"`
}
diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml
index b76a9d977..dfc4e3ece 100644
--- a/test/integration/osapi.yaml
+++ b/test/integration/osapi.yaml
@@ -51,8 +51,9 @@ nats:
replicas: 1
audit:
- bucket: audit-log
- ttl: 720h
+ stream: AUDIT
+ subject: audit
+ max_age: 720h
max_bytes: 52428800
storage: memory
replicas: 1