From b372af4f4b98653a4a4ce7f61822ff9658c341fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 19:37:02 -0700 Subject: [PATCH 01/16] docs: add audit stream migration spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../2026-04-01-audit-stream-migration.md | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/plans/2026-04-01-audit-stream-migration.md 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..ffc8cf37f --- /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 From dd9261034710796772d10408ed73a7790858d6e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:05:48 -0700 Subject: [PATCH 02/16] docs: add audit stream migration implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../2026-04-01-audit-stream-migration-plan.md | 806 ++++++++++++++++++ 1 file changed, 806 insertions(+) create mode 100644 docs/plans/2026-04-01-audit-stream-migration-plan.md 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..6a83cd282 --- /dev/null +++ b/docs/plans/2026-04-01-audit-stream-migration-plan.md @@ -0,0 +1,806 @@ +# 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. From 18e41ecca118ba5ed4f5527cedb074843aa5f67f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:16:28 -0700 Subject: [PATCH 03/16] chore(config): rename audit config from KV bucket to stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace NATSAudit.Bucket/TTL with Stream/Subject/MaxAge fields to reflect the migration from KV bucket to JetStream stream storage. Remove audit from AllKVBuckets() and update tests accordingly. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.yaml | 7 ++++--- internal/config/nats.go | 1 - internal/config/nats_public_test.go | 8 +------- internal/config/types.go | 10 ++++++---- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/configs/osapi.yaml b/configs/osapi.yaml index 14a29fff5..bfac9d191 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -90,10 +90,11 @@ nats: replicas: 1 audit: - bucket: audit-log - ttl: 720h + stream: 'AUDIT' + subject: 'audit' + max_age: '720h' max_bytes: 52428800 - storage: file + storage: 'file' replicas: 1 registry: 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..4fbde7bb4 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"` From f0f8bcf94aee0c86dba1440bf5b3c24352a7caa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:18:19 -0700 Subject: [PATCH 04/16] feat(audit): replace KV bucket setup with stream creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace BuildAuditKVConfig with BuildAuditStreamConfig to build a jetstream.StreamConfig instead of KeyValueConfig. Update nats_setup.go to create an audit stream instead of a KV bucket, and update tests. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/nats_setup.go | 20 +++++++++++---- internal/cli/nats.go | 28 ++++++++++++++------- internal/cli/nats_public_test.go | 43 +++++++++++++++++++------------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/cmd/nats_setup.go b/cmd/nats_setup.go index 0a172d285..e0abeb16a 100644 --- a/cmd/nats_setup.go +++ b/cmd/nats_setup.go @@ -147,11 +147,21 @@ func setupJetStream( return fmt.Errorf("create KV bucket %s: %w", kvResponseBucket, err) } - // Create audit KV bucket with configured settings - if appConfig.NATS.Audit.Bucket != "" { - auditKVConfig := cli.BuildAuditKVConfig(namespace, appConfig.NATS.Audit) - if _, err := nc.CreateOrUpdateKVBucketWithConfig(ctx, auditKVConfig); err != nil { - return fmt.Errorf("create audit KV bucket %s: %w", auditKVConfig.Bucket, err) + // Create audit stream with configured settings + 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, + ) } } 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) }) From 95e60a706ed8f0790825adab5d9c76fba6921887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:21:03 -0700 Subject: [PATCH 05/16] feat(audit): add trace_id field for OpenTelemetry correlation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TraceID to audit entries across all layers: domain type, OpenAPI spec, API handler mapping, middleware extraction, and SDK types. The field is optional and populated from the OTel span context when tracing is enabled. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/gen/api/get-audit-export.api.mdx | 13 +++++++++++-- docs/docs/gen/api/get-audit-log-by-id.api.mdx | 13 +++++++++++-- docs/docs/gen/api/get-audit-logs.api.mdx | 13 +++++++++++-- internal/audit/types.go | 2 ++ internal/controller/api/audit/audit_list.go | 3 +++ internal/controller/api/audit/gen/api.yaml | 4 ++++ internal/controller/api/audit/gen/audit.gen.go | 3 +++ internal/controller/api/gen/api.yaml | 4 ++++ internal/controller/api/middleware_audit.go | 8 ++++++++ pkg/sdk/client/audit_types.go | 5 +++++ pkg/sdk/client/gen/client.gen.go | 3 +++ 11 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/docs/gen/api/get-audit-export.api.mdx b/docs/docs/gen/api/get-audit-export.api.mdx index bb674b2e0..859810307 100644 --- a/docs/docs/gen/api/get-audit-export.api.mdx +++ b/docs/docs/gen/api/get-audit-export.api.mdx @@ -5,7 +5,7 @@ description: "Returns all audit log entries without pagination for export." sidebar_label: "Export all audit log entries" hide_title: true hide_table_of_contents: true -api: eJztV21v4kYQ/iur+dRKBgwH6Z0/lbbJHae7NkqJIjVCaPEOsBd719kdJ6HI/72aNRAMpI3Uq9QP9wnbzMuz88zbrsEW6CRpa0YKEniPNCyVpvOnwjqCCBT61OmCBSCBK6TSGS9klgnJciKzC4GGnEYvHjUtbUmikAttgk0xt05gsNWGCEguPCS3EFxMP9nFdHg5mgZDMInAY1o6TStIbtfwE0qHbljSkjWCTOJQKphUkwgc+sIajx6SNfTimH+aUIc7iBt4DCC1htAQS8uiyHQaUHa+eFZZg0+XmEt+olWBkICdfcGUw1A4jhPp2iFZktlUE+Z+T1gbwgW6o6CNWVqYMp+hE3Z+jAqfZF5kCEl3EFcRHNqVzskVHH9/AZxWezKenDYLiGBuXS4JEihLrY4gXht9X6LQCg3puUYXiKMl7oFdNaDCYBDj234ct7D3btbqd1W/JX/onrX6/bOzwaDfj+M4hioC0jl6knnxt6iUJGyx6BG0myWagMThfYmexKP0onA2Re9RNTH14t5ZK+61et1xN07exEkc/8EYSo/ulPuDjClpyedPJaESrCO++3gzFr4Mgf6+6csW/sfNWzu1ObtxNsPXELfxXx1VF+uLubN5ODD7JnuHpuH4FqTKtYFJFUGOtLQn6W4a/jAeX4pauHmI9+djhlFIrrJ/snK1IeD66pNgjaapjrEKO0vrycgc2equs0xPp2TT+m8FmuHlSOy0xOiXA7BIv1qFH/ZceFu6FKf6ZHI17f+caTQkRpdCKuXQNysPuu967e7Z23a33a3zdtthpqlV+IoqDzHeKglPkkovWLfhpxdzhatyE5gX+seuLLShs/6LRGzNCG1ErrNMe0ytUc2T9XtVOM19qR0qzp9Q/89luSmPbfrusmqTFvtBPoxK8yQTtkohmvUM4aZxnOXjRlfhuVH3Gu15cGAbDvHud9ttMe35+qR9PbOuNtigqthEP+4eT4VrI0taWqf/RCVaghPuDldi6+4rzgh0zr6m6Yi9d54OXPlBV9BSkrBpWjp32OgupM5QCbLCIcfwYZtx7TreJHV2ouMcOVdK86PMxEZHyBlP8B2Ik25ViezaID1adyc4lWxJwfUri2W8O+RRhQziuKqe2T1nqSNm3xwze2HdTCuFRrTEyPhyPtdpqPgCXa6919Z8zRXgG73/Gb2DU+vcyBA6PolH94DuGf43Pv/ffO6tKbBA2o6VBDphCHRwe9OoieX7wd5V4Hemr2Zo/0KwQ7wk4rEUWOb3WRCCaPNwsR2kH2/GYa5oM7dBfTuoFtwiPksjF5jz4/ByBBEwkPro3XbcDvtAYT3lMuRU2D4SqO9Ip+9ChyFcP+fpv75E1ScnfKJOkUltwobrMnZSh3ZzXQo0heBOIuDFjP9Zr2fS47XLqoo/35foVnXIH6TTchZWTN4tlygVb823a7jDFYcqTbFgow8yK8N6e1hufDHbcc2rZQSySdcBPcH6dlk2qz3b63UtMeb9t6og2oAI+zBUk6qq/gJUqQ7w +api: eJztV1tv4kYU/iujeWolAwYM2fiptJvsstpto5QoUiOEBs8BZmPPODPHSSjyf6/OGAjGpI3UrdSHfcI25/LN+c5tNtzkYAUqo8eSx/wD4KiQCi+ec2ORB1yCS6zKSYDH/BqwsNoxkaZMkBxLzZKBRqvAsSeFK1Mgy8VSaW+TLYxl4G21ecBRLB2P77h3MftslrPR1XjmDfFpwB0khVW45vHdhv8MwoIdFbgiDS8TWxCST8tpwC243GgHjscb3gtD+qlDHe0hbuERgMRoBI0kLfI8VYlH2fnqSGXDXbKCTNATrnPgMTfzr5BQGHJLcUJVOUSDIp0phMwdCCuNsATbCNqEpJkusjlYZhZNVPAssjwFHncHYRnwY7vCWrHmze+vgFPyQMahVXrJA74wNhPIY14USjYg3mj1UABTEjSqhQLricMVHIBd16DywSCEd1EYtqB3Pm9FXRm1xFl32Iqi4XAwiKIwDENeBhxVBg5Flv8tKikQWiTagHa7Au2RWHgowCF7Eo7l1iTgHMg6pl7YG7bCXqvXnXTDuB/GYfgHYSgc2FPujzKmwBWdPxEIkpEO++HT7YS5wgf6x7ovk7uftm/txGTkxpoU3kLc1n/ZqC7SZwtrMn9g8o3mHnTN8R0XMlOaT8uAZ4Arc5LuuuGPk8kVq4Trh/hwMSEYuaAq+ycr11sCbq4/M9Kom+poI6GzMg61yICs7jvL7HRK1q3/loMeXY3ZXouN3x+BBfzVSPh44MKZwiYwUyeTq27/l1SBRja+YkJKC65eebx73mt3h+/a3Xa3yttdh5klRsIbqtzHeKfEHAosHCPdmp9eSBUui21gXukf+7JQGofRq0TszDClWabSVDlIjJb1k0U9KkIrKExvpGECKWSAds28Hhu/9+0gMdZC6j3WYxfNF+e9RX9wdjbvR1IMRT+B8965DCGE6Kw/5KWP50OhLEjKYN+BXhrDtkB3BbTP621iHtJ8zEs9llOyih5TNcWobTXrbFLrazS5qm6nHI0uaDfwHvb7XTkf+PqsXDU1r7fYeFmSiSjsNufSjRYFroxVf4JkLUYpfw9rtnP3DacUWGve0vbYwTvNJ+o9XpfhSiAzSVJYe9xqL4VKQTI0zALF8HGX8+0q3ihUeqLnNZxLqehRpGyrw8Scdog9iJNuZQHkWgM+GXvPKJVMgd71G8t1sj9ko0YHYViWL+xekFSD2X6T2Utj50pK0KzFxtoVi4VKfM/JwWbKOWX0t1xCvtP7n9E7OLVQjjWCpZM4sI9gX+B/5/P/zefBosSXgLuxEvOOHwId2N11KmLphnJwGfmd6KsYOryS7BGvEGkseZbpfe6FeLB9uNyN8k+3Ez9XlF4Yr74bVEtqEV+EFkvI6HF0NeYBJyDV0bvtsO03ktw4zITPKb//xLy6pZ2+jR2HcPOSp//6GledHOEZO3kqlPY7tk3JSRXa7YXN0+SDOw04rYb0z2YzFw5ubFqW9PmhALuuQv4orBJzv+TSdrsCIWlvv9vwe1hTqJIEcjL6KNLCL9jH5UZXwz3XtNwGXNTpOqLHW9+t63p9YHuzqSQmtIGXJQ+2IPxGzstpWZZ/AVrrOZ0= sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -242,6 +242,15 @@ Returns all audit log entries without pagination for export. schema={{"type":"integer","format":"int64","description":"Request duration in milliseconds.","example":42}} > + +
  • diff --git a/docs/docs/gen/api/get-audit-log-by-id.api.mdx b/docs/docs/gen/api/get-audit-log-by-id.api.mdx index 754e5b6aa..8e2886404 100644 --- a/docs/docs/gen/api/get-audit-log-by-id.api.mdx +++ b/docs/docs/gen/api/get-audit-log-by-id.api.mdx @@ -5,7 +5,7 @@ description: "Returns a single audit log entry by ID." sidebar_label: "Get a single audit log entry" hide_title: true hide_table_of_contents: true -api: eJztV21v20YM/isHfmoB2ZFdJ2v1ae6atC66LcgcBFgQBGcdbV8j3al3VFrP0H8feJJfZDtJB3TABuSTJZsvD/mQNLkEW6CTpK0ZKUjgPdKwVJo+2dnbxegdRKDQp04XLAEJXCCVznghhddmlqGQLC0yOxNoyC3EZCFG77oQAcmZh+QagrnbT3Z2Ozwf3QZxuInAY1o6TQtIrpfwFqVDNyxpzhpBJnEoFdxUNxEU0skcCZ0PwkbmCAloBRFoxlRImkMEDr+U2qGChFyJEfh0jrmEZAm0KFjDk9NmBhFMrcslQQJlqRVUuzGO56uw6pA4Hsbh0BfWePRssx/H/NHWHG5pTW1pFCcitYbQEEvLosh0GrJ99NmzynIfpp18xpQggsIxN6Rrh8Hq02JaPR3xbsCXRn8pUWiFhvRUoxNT6wS108Ch4DeZFxlbPj6O8fUgjjvYfzPpDHpq0JE/9U46g8HJyfHxYBDHccyZJZ2jJ5kXj6JSkrDDonvQruZoAhJmFz2Jr9KLwtkUvUfVxtSP+yeduN/p98a9OHkVJ3H8J2MoPbpD7ne5oznHn0pCJVhHvPh4NRa+DIl+2fZlC/9z89ZNbc5unM1qCho/0jm54BolzP2+/72yu2B9MXU2DwGzb7J3aFqOr0GqXBu4qSLIkeb2IN1twx/G43NRC7eDeH86Zhihf560ctEQcHnxSbBG29SRsQqP5tZTaM8q2syV28Ml2bb+e4FmeD4Sa61mimyBRfrNKvyw5cLb0qV4qw8WV9v+L5lGQ2J0LqRSDr1vG++96Xd7J6+7vW6vrttVr9+mVuGWeW0IZ+gO53ilJDxJKr1g3Zaffhwz7WWTmFZZbCyv20IbOhk8SMTKjNBG5DrLtMfUGtWObNCvqu3ReF3PzU1bNu2xKt91VUWrsbpJ8m5W2pHcsFUK2Qxj8DTMq13v9RQ7KHvRGIeqYq1B3NsfsJdGljS3Tv+FSnQEV8wdLsTKw48ct87Z75kaYutd2Glo3aAraC5J2DQtndudVGdSZ6gEWeGQnMb7Vcl067FAUmcHRsaec6U0P8pMNDpCTmxJGxAH3aoS2bVB+mrdneBasCUF199Z7eN1kHslfhzHVbWh95Sl9ph9tc/smXUTrRQa0REj48vpVKehZQt0ufZeW+Of6f1/0Dt4fDMyln78dvTM57/F5/GhTXdkCB1H4tHdo9vAf+bzv83n1t4IM6TV/3wCR2HXP1pqVfG/fqCVD66tW+0PJq/mZ/tiW+OdE/GWEDjm90kQgqh5OFvtNR+vxmEx0GZqg/pqF5jxwP9VGjnDnB+H5yOIgIHUgfe6cTesZ4X1lMtQUc05+B7pwaN0N4HLTZX+k4O2DpLwGx0VmdQm3BYuY3t1DpvTFSJItOIlh/dh/nq5nEiPly6rKv76S4l8ynFq76XTchI2+yUo7flZQTKVmcdHUL+4aDael+LQtfoA2NVhYjgj9zIr+Q0iuMNFfVBXfFbMUSo+mK6XzQ/DNMWCtlT2Gpuv43VV8VURgWyXxk4pBOsH4SyXtcSYT5+qWqMLpxADrKq/AUdatiw= +api: eJztV9tu20YQ/ZXFPDUAJVMyJcd8qtLcFKSt4coIUMMwVtyRtDG5y+wOnagC/72YpW6UlEuBFGiBPImU5n5mjmZWYEt0krQ1YwUpvEIaVUrTWzt/thw/hwgU+szpkiUghWukyhkvpPDazHMUkqVFbucCDbmlmC7F+HkXIiA595DeQjB3/9bO70dX4/sgDncReMwqp2kJ6e0KnqF06EYVLVgjyKQOpYK7+i6CUjpZIKHzQdjIAiEFrSACzTGVkhYQgcMPlXaoICVXYQQ+W2AhIV0BLUvW8OS0mUMEM+sKSZBCVWkF9WGOk8UmrSYlzofjcOhLazx6ttmPY/5oa472tGa2MooLkVlDaIilZVnmOgvVPnvvWWV1HKadvseMIILSMTakG4fB6tfFtPp6xocJ3xj9oUKhFRrSM41OzKwT1C4Dp4KfZFHmbHkwiPFpEscd7F9OO0lPJR150Rt2kmQ4HAySJI7jmCtLukBPsii/GJWShB0WPQrt3QJNiITRRU/io/SidDZD71G1Y+rH/WEn7nf6vUkvTs/jNI7/5Bgqj+6U+0PsaMH5Z5JQCdYRP715NxG+CoV+0vZlS//z+q2b2YLdOJs3EKz9SOfkknuUsPDH/o/a7pr1xczZIiTMvsk+oGk5vgWpCm3gro6gQFrYk3C3Db+eTK5EI9xO4tWLCYcR5uerVq7XANxcvxWs0TZ1ZqzCs4X1FMazjna8cn+6JdvWfy/RjK7GYqu1ZpG9YJF+swpf77nwtnIZ3uuTzdW2/0uu0ZAYXwmplEPv28Z7l/1ub/i02+v2mr7dzPp9ZhXumdeGcI7udI03SsKTpMoL1m356ccxw16tC9Nqi53l7VhoQ8Pks0BszAhtRKHzXHvMrFHtzJI+D6GTXKZvhGGCORbIFBb0xPh5oIPMOod58NiuXTKdXfZn54OLi+l5ouRQnmd42b9UMcaYXJwPoa73yfm2Ye4dMawHdDNA276ONsS+g/kQl3Yt79gqhZgCEb8IjHnoveHRk7LXa+NQ16yVxL1jir8xsqKFdfovVKIjuGcfcCk2Hr4n4Ttnv4W3xN67sLNAHkFX0EKSsFlWOXfIlS+lzlEJssIhOY2Pm6btNsREUucnSOvIuVKaH2Uu1jpCTm1FuyBOulUVsmuD9NG6B8G9YCsKrr9x3ibbJI+GbBDHdb2D9wVLHSF7fozsS+umWik0oiPGxlezmc4CaZToCu29tsb/gPf/AW/y5d3MWPr++9kPPP8tPAendu2xIXSciUf3iG4X/g88/9t47m2uMEfa/M+ncBaujbOVVjX/6wdY+eTbuxb/YPAafPZvxm28CyLeEgLG/D4NQhCtH15uNqs37yZhMdBmZoP6ZheYM+H/Ko2cY8GPo6sxRMCBNIn3unE3LIil9VTI0FHrg/QV0mfP4sMCrnZd+k9O6iZJwk90VuZSm3DduJztNTVcH88QQaoVLzm8kfPXq9VUerxxeV3z1x8q5GOSS/sonZbTcFusQGnPzwrSmcw9fiHqn67XG88Tcepe/kywm9PIcEUeZV7xG0TwgMvmpK/5sFmgVHyy3a7WP4yyDEvaUzkabL7Pt13Fd00Est0aB60QrJ8MZ7VqJCZ8fNX1NrpwjHGAdf030A3g2Q== sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -251,6 +251,15 @@ Returns a single audit log entry by ID. schema={{"type":"integer","format":"int64","description":"Request duration in milliseconds.","example":42}} > + +
    @@ -262,7 +271,7 @@ Returns a single audit log entry by ID. value={"Example (from schema)"} > diff --git a/docs/docs/gen/api/get-audit-logs.api.mdx b/docs/docs/gen/api/get-audit-logs.api.mdx index b1e09c51e..38f0b0e0b 100644 --- a/docs/docs/gen/api/get-audit-logs.api.mdx +++ b/docs/docs/gen/api/get-audit-logs.api.mdx @@ -5,7 +5,7 @@ description: "Returns a paginated list of audit log entries, newest first." sidebar_label: "List audit log entries" hide_title: true hide_table_of_contents: true -api: eJztWG1v2zYQ/ivEfdoA2ZFdJ2sNFFi29cVD2gWZgwILDIMWzzYbiVTJUxrP0H8fjpJtyXY6A9uAFegnS/K9PPfChzyuweboJGlrRgqG8AbpslCaruzCQwQKfeJ0zn/DEG6QCme8kCKXC20koRKp9iTsXEjWEqldCDTkNPpIGPyMnsRcO09diIDkwsPwDoKD6ZVdTC+vR9OgCJMIPCaF07SC4d0afkLp0F0WtGSNIDN0KBVMykkEuXQyQ0Lng7CRGcIQUp1pggg0Y/1UoFtBBA4/FdqhguFcph4jeOxYmetOYhUu0HTwkZzsVNDW8CBTrSSxNZtpwiynVZRp87IXZfLxZS+OoYzAJ0vMJMvTKmdZbQgX6ELC5rJICYb9OIJMG50VGQx7EWTysX6O43I/se+qP4Upshk6TmedREFWuJD1LpTRNlI7n3v8r0I9OcRmhIcxvT8Wi7/XeRe4hA59bo3HgKUfx/zTNnDV6qzaCPdRYg2hIdaQeZ7qJLTv2UfPautD6Hb2ERPOVu642UlXTsmSTKccuX8iziaaMUs3CnSACh9llqcIw945J2PfrnROcpH2vz8BTquGjCenzQIimFuXSYIhFIVWBxBvjf5UoNAKDem5Rifm1glaYgPsqgUVzs9jfD6I4w72X8w6g54adOQPvYvOYHBxcX4+GMRx1fGkM/Qks/yLqLidOix6AO3DEk1Awj3KpPBZepE7m6D3qNqY+nH/ohP3O/3euBcPn8XDOP6DMRQe3TH3bU9MGRx/EtiJdcR3v34YC1+ERH/f9mVz/2P91k1sxm6cTfGUwtX+D/r+hvXF3NksBMy+yd6jaTm+A6kybWBSRpAhLe3RcrcNvx2Pr0Ul3A7izasxw8glk+XfWbmpC3B7cyVYo23qzFiFZ0vrKTBNGe22h+nxlmxb/y1Hc3k9ElstMfplDyzSe6vwbcOFt4VLcKqPNlfb/s+pRkNidC2kUg59e+VB70W/27t43u11a6besMyUOfCEVR5yvFESniQVXrBuy0+/ovCiTswT/LFdFtrQxeDJQmzMCG1EptNUe0ysUe3IBv2ybBL8HYT1v1uW9fLYtO+2q+q2aCZ5PyvtSCZslUI2wz79iknjsMvHLVZhdq+4Rns+G2AX9vE22XazmBq+mOyDv5saG5Qlmxgc2xlGJmxeWzLZHQf+xe0BnbOn8I1ovPPGwIs+6ApaShI2SQrn9jnutdQpqnp3dxofNs3WrVJNUqdHyObAuVKaH2Uqah0hZ7agHYijblWB7NogfbbuXnAX2YKC6xPXyXgb5MHiOI/jstwV9hVLHRS1d1jUWyMLWlqn/0QlOoJZ5B5XYtND3yr7dVT22WFlX1s300qhER0xMr6Yz3USaDxHl2nvtTXfFu5XUd7z42xM6DgSj+4B3Q7+t3r+v+vZOHvCIoyT1RESzqqZnEdyrigP2Y35/HeuW1Wa5pS+hbok4kNGKC+/z4IQRPXD682x6NcP43BK0GZug/rm2LFgbngnjVxgxo+X1yOIgIFUMfe6cTec7nLrKZOhmerBOMyMB1cR+1lb71rzH99pVDETPtJZnkptwqTiUnZSZbO+veDDDp+r+cN6PZMeb11alvy5mt/5IkNpL2dpY4J/EvZJNwZPwLvHVeO25EGmBcuE+4XTAXxhvP+i2+3Vxc7vhF+cZsfcaWUES5SKh727da11mSSYN7UOCIWtbLuZJ6IIZLsv9/owWN/MeGbVsL1eVxJjHtvKEjbQwxgH5aQsy78AqySZTA== +api: eJztWG1v2zYQ/isEP22A7MiO7TQGCixb3zykXZA5KLDAMGjxZLORSJU8pfEM/ffhKNmWLKczsA1YgX6yJPPunnvhw+NtuMnAClRGTyQf87eAV7lUeG2WjgdcgousyuhvPua3gLnVjgmWiaXSAkGyRDlkJmaCpFhilgw0WgUuYBq+gEMWK+uwywOOYun4+J57A/Nrs5xf3UzmXpDPAu4gyq3CNR/fb/jPICzYqxxXJOHXjC0IyWfFLOCZsCIFBOv8Yi1S4GOeqFQhD7girJ9zsGsecAufc2VB8nEsEgcBf+oYkalOZCQsQXfgCa3olNA2/FEkSgokbSZVCGmG6yBV+mUvSMXTy14Y8iLgLlpBKmg9rjNaqzTCEqwPWCzyBPm4HwY8VVqlecrHvYCn4ql6DsPiMLDvyz+ZztMFWApnFUSGhlkf9S4vgp2nJo4d/Feunuxi3cO2Tx+O+eIeVNbllEILLjPagcfSD0P6aSq4blRWpYTqKDIaQSNJiCxLVOTL9+yTI7FNG7pZfIKIopVZKnZUpVE0KJI5ee6e8bOOZkqrawlqoYInkWYJ8HFvSME41CusFZSkw+/PgFOytsahVXrJAx4bmwrkY57nSrYg3mn1OQemJGhUsQLLYmMZrqAGdt2AyofDEF4MwrAD/ctFZ9CTg4646I06g8FoNBwOBmFYVjyqFByKNPsqKiqnDi1tQfu4Au2RUI0SKXwRjmXWROAcyCamftgfdcJ+p9+b9sLxeTgOwz8IQ+7AHjPftESUQf5Hnp1Ihv3w68cpc7kP9I9NWyZzP1Vv3cikZMaaBE5JXGW/Vfe3JM9ia1LvMNlG8wC6YfieC5kqzWdFwFPAlTma7qbid9PpDSsXN514+3pKMDJBZPl3Wm6rBNzdXjOSaKo600bC2co49ExTBPvjYX68JJvaf8tAX91M2E6KTV4dgAX8YCS8q5lwJrcRzNXR4mrq/yVRoJFNbpiQ0oJr7jzeu+x3e6MX3V63Yuoty8yJA0/Y5T7GWyHmUGDuGMk27PRLCs+rwDzDH7ttoTSOBs8mYquGKc1SlSTKQWS0bHo26NMmtILCdGIappBACmjXzMuxyStPB5GxFhJvsRm7wSK+7Mfnw4uLxflAipE4j+CyfylDCGFwcT7iRVE/Yu65Z6A9MVQbdLuBdnVdFWY9zYd5acZyRlrRY/KdwmuirfY+mzZ4jc6Xku2Uo+4Eui28db7fbueaLTpuvL3bChsvClIxOHY2TbQ/Pnd0tm9I/sUDCqw1pzAeq73T0US042UZrgQyE0W5tYcs+0aoBGTVX1gFj9ty75ahRqGSI3TXMi6lokeRsEqGiYXJcQ/iqFmZA5nWgF+MfWBURSZHb/rEnTrdOdnansMwLIp9Yl/TqlZSe+2k3mmR48pY9SdI1mHEYw+wZtsa+p7ZbyOz5+3MvjF2oaQEzTpsol0exyryB0kGNlXOKaO/b9xvIr3D42yMYMkTB/YR7B7+93z+v/NZ63750l9oyyaWn5VTARoKUEbpml+bEPxOeStTU58T7KCuEKnJ8Oml94VfxIPq4c22Mfv149R3CUrHxotv244lccN7ocUSUnq8upnwgBOQ0udeN+z6/jIzDlPhi6m6mvtba2sYchi1zb40//FUpfQZ4QnPskQo7e9KNiEjZTSr+Qk1O9TZ04fNZiEc3NmkKOhzOUGgUYpUTiyS2gzhWdgnzSyegfcA69q85lEkOa3xE47TAXxlwPBVs7vhyd7ujF6sIsNUaUXAVyAkXTfvN5XUVRRBVpdqEQpp2VUz3ckCLpp1eVCHXvv2lqnXNd2bTbliShfHouBb6P4iyYtZURR/Acxvw/k= sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -279,6 +279,15 @@ Returns a paginated list of audit log entries, newest first. schema={{"type":"integer","format":"int64","description":"Request duration in milliseconds.","example":42}} > + +
  • 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/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/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/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/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"` } From 652f83fd52a4090fd4f62c05710a862a756b8d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:28:54 -0700 Subject: [PATCH 06/16] feat(audit): implement stream-based audit store Replace KVStore with StreamStore backed by JetStream stream. Write publishes to per-entry subjects, Get uses GetLastMsgForSubject, and List/ListAll use ordered consumers with newest-first pagination. Co-Authored-By: Claude --- internal/audit/kv_store.go | 193 ------ internal/audit/kv_store_public_test.go | 422 ------------- internal/audit/kv_store_test.go | 86 --- internal/audit/mocks/consumer.gen.go | 178 ++++++ internal/audit/mocks/generate.go | 5 + internal/audit/mocks/message_batch.gen.go | 63 ++ internal/audit/mocks/msg.gen.go | 207 +++++++ internal/audit/mocks/publisher.gen.go | 49 ++ internal/audit/mocks/stream.gen.go | 374 ++++++++++++ internal/audit/stream_store.go | 244 ++++++++ internal/audit/stream_store_public_test.go | 673 +++++++++++++++++++++ 11 files changed, 1793 insertions(+), 701 deletions(-) delete mode 100644 internal/audit/kv_store.go delete mode 100644 internal/audit/kv_store_public_test.go delete mode 100644 internal/audit/kv_store_test.go create mode 100644 internal/audit/mocks/consumer.gen.go create mode 100644 internal/audit/mocks/message_batch.gen.go create mode 100644 internal/audit/mocks/msg.gen.go create mode 100644 internal/audit/mocks/publisher.gen.go create mode 100644 internal/audit/mocks/stream.gen.go create mode 100644 internal/audit/stream_store.go create mode 100644 internal/audit/stream_store_public_test.go 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..38f990e4f --- /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() + wantErr bool + errMsg string + }{ + { + 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) + }, + wantErr: false, + }, + { + 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")) + }, + wantErr: true, + errMsg: "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") + }) + }, + wantErr: true, + errMsg: "marshal audit entry", + }, + } + + 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) + s.Contains(err.Error(), tt.errMsg) + } else { + s.NoError(err) + } + }) + } +} + +func (s *StreamStorePublicTestSuite) 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() { + s.mockStream.EXPECT(). + GetLastMsgForSubject(gomock.Any(), "audit.log.entry-1"). + Return(&jetstream.RawStreamMsg{Data: data}, 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) + 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) + }, + validate: 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) + }, + 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 *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() + validate 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) + }, + validate: 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) + }, + 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.mockStream.EXPECT(). + Info(gomock.Any()). + Return(s.newStreamInfo(3, 1), nil) + }, + validate: 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) + }, + validate: 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")) + }, + validate: 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")) + }, + validate: 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")) + }, + validate: 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) + }, + 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: "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) + }, + validate: 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.validate(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() + validate 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) + }, + validate: 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) + }, + validate: 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")) + }, + validate: 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")) + }, + validate: 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")) + }, + validate: 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) + }, + validate: 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) + }, + validate: 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.validate(entries, err) + }) + } +} + +func TestStreamStorePublicTestSuite(t *testing.T) { + suite.Run(t, new(StreamStorePublicTestSuite)) +} From 8146a08e05edc5e71b171f3de16624b80c59a414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:32:30 -0700 Subject: [PATCH 07/16] feat(audit): wire stream store in controller setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace KV-based audit store wiring with stream-based approach. The controller now creates a JetStream stream, resolves it by name, and passes it to NewStreamStore along with the NATS client as publisher. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/controller_setup.go | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/cmd/controller_setup.go b/cmd/controller_setup.go index 373eaa3ed..4f8d2b2d7 100644 --- a/cmd/controller_setup.go +++ b/cmd/controller_setup.go @@ -643,17 +643,36 @@ func createAuditStore( nc NATSClient, namespace string, ) (audit.Store, []api.Option) { - if appConfig.NATS.Audit.Bucket == "" { + if appConfig.NATS.Audit.Stream == "" { return nil, nil } - auditKVConfig := cli.BuildAuditKVConfig(namespace, appConfig.NATS.Audit) - auditKV, err := nc.CreateOrUpdateKVBucketWithConfig(ctx, auditKVConfig) + 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 create audit KV bucket", err) + cli.LogFatal(log, "failed to get audit stream", err) } - store := audit.NewKVStore(log, auditKV) + subject := job.ApplyNamespaceToSubjects( + namespace, + appConfig.NATS.Audit.Subject, + ) + + store := audit.NewStreamStore(log, stream, nc, subject) return store, []api.Option{api.WithAuditStore(store)} } From 6b3f78c9f86ad06cfa40028d74ca249fc06d4484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:34:29 -0700 Subject: [PATCH 08/16] docs(audit): update config reference for stream migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update configuration.md and audit-logging.md to reflect the migration from NATS KV bucket to JetStream stream. Config fields bucket→stream, ttl→max_age, new subject field. Add trace_id to audit entry table. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/audit-logging.md | 33 ++++---- docs/docs/sidebar/usage/configuration.md | 36 +++++---- .../2026-04-01-audit-stream-migration.md | 78 +++++++++---------- 3 files changed, 78 insertions(+), 69 deletions(-) diff --git a/docs/docs/sidebar/features/audit-logging.md b/docs/docs/sidebar/features/audit-logging.md index d5ba0f7bf..85727f584 100644 --- a/docs/docs/sidebar/features/audit-logging.md +++ b/docs/docs/sidebar/features/audit-logging.md @@ -12,20 +12,20 @@ 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 ``` @@ -43,6 +43,7 @@ Each audit entry contains: | `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/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.md b/docs/plans/2026-04-01-audit-stream-migration.md index ffc8cf37f..78a8d6a8e 100644 --- a/docs/plans/2026-04-01-audit-stream-migration.md +++ b/docs/plans/2026-04-01-audit-stream-migration.md @@ -1,21 +1,20 @@ # Audit Stream Migration -Migrate the audit store from NATS KV to a JetStream stream for -chronological ordering and efficient pagination. +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. +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. +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 @@ -42,8 +41,8 @@ nats: replicas: 1 ``` -Fields renamed: `bucket` -> `stream`, `ttl` -> `max_age`. New field: -`subject` (base subject for audit messages). Drop `bucket` entirely. +Fields renamed: `bucket` -> `stream`, `ttl` -> `max_age`. New field: `subject` +(base subject for audit messages). Drop `bucket` entirely. ### Audit Entry @@ -65,8 +64,8 @@ type Entry struct { } ``` -The `ID` field changes from UUID to ULID. This is the only breaking -change — no backward compatibility needed. +The `ID` field changes from UUID to ULID. This is the only breaking change — no +backward compatibility needed. ### Store Interface @@ -83,18 +82,17 @@ type Store interface { ### 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` | +| 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: +Extract trace ID from OpenTelemetry span context in the audit middleware: ```go spanCtx := trace.SpanContextFromContext(c.Request().Context()) @@ -106,6 +104,7 @@ if spanCtx.HasTraceID() { ### 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 @@ -121,6 +120,7 @@ Production code: - `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 @@ -129,24 +129,24 @@ Test code: ### Coverage Baseline -All files below are currently at 100% coverage. The new -implementation must maintain 100%: +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% | +| 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 +- `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 From 9125a85c92c4203f0f23aea385da0abb7ba65bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:38:11 -0700 Subject: [PATCH 09/16] chore(config): fix goimports formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/config/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/types.go b/internal/config/types.go index 4fbde7bb4..654ecfd00 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -124,7 +124,7 @@ type NATS struct { // NATSAudit configuration for the audit log stream. type NATSAudit struct { // Stream is the JetStream stream name for audit log entries. - Stream string `mapstructure:"stream"` + 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) From e40adfed8f0a490e8e30dbba39cdf13199b237b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:41:42 -0700 Subject: [PATCH 10/16] docs: fix audit-logging table formatting and reflow plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/audit-logging.md | 24 +-- .../2026-04-01-audit-stream-migration-plan.md | 187 +++++++++--------- 2 files changed, 106 insertions(+), 105 deletions(-) diff --git a/docs/docs/sidebar/features/audit-logging.md b/docs/docs/sidebar/features/audit-logging.md index 85727f584..981a0b666 100644 --- a/docs/docs/sidebar/features/audit-logging.md +++ b/docs/docs/sidebar/features/audit-logging.md @@ -31,18 +31,18 @@ sequenceDiagram 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 diff --git a/docs/plans/2026-04-01-audit-stream-migration-plan.md b/docs/plans/2026-04-01-audit-stream-migration-plan.md index 6a83cd282..1c7b15261 100644 --- a/docs/plans/2026-04-01-audit-stream-migration-plan.md +++ b/docs/plans/2026-04-01-audit-stream-migration-plan.md @@ -2,28 +2,26 @@ > **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. +> 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. +**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. +**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 +**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) @@ -80,9 +78,9 @@ 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: `internal/cli/nats_public_test.go` (update test for renamed function) - Modify: `cmd/nats_setup.go:150-155` - [ ] **Step 1: Replace BuildAuditKVConfig with BuildAuditStreamConfig** @@ -121,9 +119,9 @@ func BuildAuditStreamConfig( - [ ] **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. +`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** @@ -150,11 +148,10 @@ if appConfig.NATS.Audit.Stream != "" { - [ ] **Step 4: Run tests and verify build** -Run: `go test ./internal/cli/... -count=1` -Run: `go 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). +Expect: cli tests pass, build still has errors in controller_setup.go (expected +— fixed in Task 4). - [ ] **Step 5: Commit** @@ -167,10 +164,10 @@ 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/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` @@ -201,14 +198,13 @@ 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" +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). +Do NOT add it to `required` — it's optional (empty when tracing is disabled). - [ ] **Step 3: Regenerate OpenAPI code** @@ -238,8 +234,8 @@ if spanCtx.HasTraceID() { } ``` -Add this after building the `entry` struct and before the goroutine -that writes it. +Add this after building the `entry` struct and before the goroutine that writes +it. - [ ] **Step 6: Add TraceID to SDK audit types** @@ -259,14 +255,13 @@ if g.TraceId != nil { - [ ] **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` +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. +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** @@ -279,6 +274,7 @@ 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) @@ -541,28 +537,31 @@ And add `"time"` to the imports. 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. +`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`: +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 @@ -571,35 +570,37 @@ Test `List`: - 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. +`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 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. +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: +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 @@ -608,17 +609,16 @@ new interfaces: 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. +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` +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`. @@ -633,6 +633,7 @@ 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** @@ -681,11 +682,10 @@ func createAuditStore( } ``` -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. +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** @@ -710,6 +710,7 @@ 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` @@ -718,41 +719,41 @@ feat(audit): wire stream store in controller setup 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 +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`, +| 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. +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** From fedd556e45b4f4e6399d720672216c7e5d9b0f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:47:02 -0700 Subject: [PATCH 11/16] test(audit): add trace_id branch coverage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/audit/audit_list_public_test.go | 57 +++++++++++++++++++ .../api/middleware_audit_public_test.go | 34 +++++++++++ pkg/sdk/client/audit_types_public_test.go | 40 +++++++++++++ 3 files changed, 131 insertions(+) diff --git a/internal/controller/api/audit/audit_list_public_test.go b/internal/controller/api/audit/audit_list_public_test.go index 25873f162..17d30cf29 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}, 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_public_test.go b/pkg/sdk/client/audit_types_public_test.go index 95900316d..945945b8f 100644 --- a/pkg/sdk/client/audit_types_public_test.go +++ b/pkg/sdk/client/audit_types_public_test.go @@ -56,6 +56,7 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() { 0x00, } operationID := "getNodeHostname" + traceID := "4bf92f3577b34da6a3ce929d0e0e4736" tests := []struct { name string @@ -89,6 +90,45 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() { suite.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) { + suite.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) { + suite.Empty(a.TraceID) + }, + }, { name: "when OperationId is nil", input: gen.AuditEntry{ From 3d267ed28015323d1a9efd24cec6d7c977bc9864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:51:00 -0700 Subject: [PATCH 12/16] chore(audit): fix test convention violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use validateFunc callback in TestWrite instead of inline wantErr/errMsg pattern. Rename suite receiver from 'suite' to 's' in SDK audit types test to match project convention. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/audit/stream_store_public_test.go | 32 +++++----- pkg/sdk/client/audit_types_public_test.go | 68 +++++++++++----------- 2 files changed, 50 insertions(+), 50 deletions(-) diff --git a/internal/audit/stream_store_public_test.go b/internal/audit/stream_store_public_test.go index 38f990e4f..ff0c145da 100644 --- a/internal/audit/stream_store_public_test.go +++ b/internal/audit/stream_store_public_test.go @@ -108,11 +108,10 @@ func (s *StreamStorePublicTestSuite) newStreamInfo( func (s *StreamStorePublicTestSuite) TestWrite() { tests := []struct { - name string - entry audit.Entry - setupMock func() - wantErr bool - errMsg string + name string + entry audit.Entry + setupMock func() + validateFunc func(error) }{ { name: "successfully publishes entry", @@ -122,7 +121,9 @@ func (s *StreamStorePublicTestSuite) TestWrite() { Publish(gomock.Any(), "audit.log.entry-1", gomock.Any()). Return(nil) }, - wantErr: false, + validateFunc: func(err error) { + s.NoError(err) + }, }, { name: "returns error when publish fails", @@ -132,8 +133,10 @@ func (s *StreamStorePublicTestSuite) TestWrite() { Publish(gomock.Any(), "audit.log.entry-2", gomock.Any()). Return(fmt.Errorf("publish error")) }, - wantErr: true, - errMsg: "publish audit entry", + validateFunc: func(err error) { + s.Error(err) + s.Contains(err.Error(), "publish audit entry") + }, }, { name: "returns error when marshal fails", @@ -143,8 +146,10 @@ func (s *StreamStorePublicTestSuite) TestWrite() { return nil, fmt.Errorf("marshal failure") }) }, - wantErr: true, - errMsg: "marshal audit entry", + validateFunc: func(err error) { + s.Error(err) + s.Contains(err.Error(), "marshal audit entry") + }, }, } @@ -152,12 +157,7 @@ func (s *StreamStorePublicTestSuite) TestWrite() { s.Run(tt.name, func() { tt.setupMock() err := s.store.Write(context.Background(), tt.entry) - if tt.wantErr { - s.Error(err) - s.Contains(err.Error(), tt.errMsg) - } else { - s.NoError(err) - } + tt.validateFunc(err) }) } } diff --git a/pkg/sdk/client/audit_types_public_test.go b/pkg/sdk/client/audit_types_public_test.go index 945945b8f..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, @@ -78,16 +78,16 @@ 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) }, }, { @@ -105,7 +105,7 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() { TraceId: &traceID, }, validateFunc: func(a client.AuditEntry) { - suite.Equal( + s.Equal( "4bf92f3577b34da6a3ce929d0e0e4736", a.TraceID, ) @@ -126,7 +126,7 @@ func (suite *AuditTypesPublicTestSuite) TestAuditEntryFromGen() { TraceId: nil, }, validateFunc: func(a client.AuditEntry) { - suite.Empty(a.TraceID) + s.Empty(a.TraceID) }, }, { @@ -144,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, @@ -240,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) }, }, { @@ -255,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) }) From 7b931e0869d0bcb7c72d3053b3c8424edfcfc3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 22:57:07 -0700 Subject: [PATCH 13/16] chore(audit): fix test convention violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename validate to validateFunc in stream store tests and replace wantCode/wantContains with validateFunc callbacks in HTTP tests. Remove defer ctrl.Finish() in favor of explicit call at end of closure. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/audit/stream_store_public_test.go | 50 +++++------ .../api/audit/audit_list_public_test.go | 89 ++++++++++--------- 2 files changed, 73 insertions(+), 66 deletions(-) diff --git a/internal/audit/stream_store_public_test.go b/internal/audit/stream_store_public_test.go index ff0c145da..b1ba05dd6 100644 --- a/internal/audit/stream_store_public_test.go +++ b/internal/audit/stream_store_public_test.go @@ -170,7 +170,7 @@ func (s *StreamStorePublicTestSuite) TestGet() { name string id string setupMock func() - validate func(*audit.Entry, error) + validateFunc func(*audit.Entry, error) }{ { name: "successfully gets entry", @@ -180,7 +180,7 @@ func (s *StreamStorePublicTestSuite) TestGet() { GetLastMsgForSubject(gomock.Any(), "audit.log.entry-1"). Return(&jetstream.RawStreamMsg{Data: data}, nil) }, - validate: func(e *audit.Entry, err error) { + validateFunc: func(e *audit.Entry, err error) { s.NoError(err) s.Require().NotNil(e) s.Equal("entry-1", e.ID) @@ -196,7 +196,7 @@ func (s *StreamStorePublicTestSuite) TestGet() { GetLastMsgForSubject(gomock.Any(), "audit.log.missing"). Return(nil, jetstream.ErrMsgNotFound) }, - validate: func(e *audit.Entry, err error) { + validateFunc: func(e *audit.Entry, err error) { s.Error(err) s.Nil(e) s.Contains(err.Error(), "not found") @@ -210,7 +210,7 @@ func (s *StreamStorePublicTestSuite) TestGet() { GetLastMsgForSubject(gomock.Any(), "audit.log.bad-json"). Return(&jetstream.RawStreamMsg{Data: []byte("not-json")}, nil) }, - validate: func(e *audit.Entry, err error) { + validateFunc: func(e *audit.Entry, err error) { s.Error(err) s.Nil(e) s.Contains(err.Error(), "unmarshal audit entry") @@ -222,7 +222,7 @@ func (s *StreamStorePublicTestSuite) TestGet() { s.Run(tt.name, func() { tt.setupMock() result, err := s.store.Get(context.Background(), tt.id) - tt.validate(result, err) + tt.validateFunc(result, err) }) } } @@ -240,7 +240,7 @@ func (s *StreamStorePublicTestSuite) TestList() { limit int offset int setupMock func() - validate func([]audit.Entry, int, error) + validateFunc func([]audit.Entry, int, error) }{ { name: "returns entries newest-first within limit", @@ -274,7 +274,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Fetch(3, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(3, total) s.Len(entries, 3) @@ -313,7 +313,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Fetch(1, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(3, total) s.Len(entries, 1) @@ -329,7 +329,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Info(gomock.Any()). Return(s.newStreamInfo(3, 1), nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(3, total) s.Empty(entries) @@ -344,7 +344,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Info(gomock.Any()). Return(s.newStreamInfo(0, 0), nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(0, total) s.Empty(entries) @@ -359,7 +359,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Info(gomock.Any()). Return(nil, fmt.Errorf("connection error")) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.Error(err) s.Nil(entries) s.Equal(0, total) @@ -378,7 +378,7 @@ func (s *StreamStorePublicTestSuite) TestList() { OrderedConsumer(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("consumer error")) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.Error(err) s.Nil(entries) s.Equal(0, total) @@ -402,7 +402,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Fetch(3, gomock.Any()). Return(nil, fmt.Errorf("fetch error")) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.Error(err) s.Nil(entries) s.Equal(0, total) @@ -436,7 +436,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Fetch(2, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(2, total) s.Len(entries, 1) @@ -468,7 +468,7 @@ func (s *StreamStorePublicTestSuite) TestList() { Fetch(1, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, total int, err error) { + validateFunc: func(entries []audit.Entry, total int, err error) { s.NoError(err) s.Equal(1, total) s.Len(entries, 1) @@ -480,7 +480,7 @@ func (s *StreamStorePublicTestSuite) TestList() { s.Run(tt.name, func() { tt.setupMock() entries, total, err := s.store.List(context.Background(), tt.limit, tt.offset) - tt.validate(entries, total, err) + tt.validateFunc(entries, total, err) }) } } @@ -496,7 +496,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { tests := []struct { name string setupMock func() - validate func([]audit.Entry, error) + validateFunc func([]audit.Entry, error) }{ { name: "returns all entries newest-first", @@ -527,7 +527,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Fetch(3, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.NoError(err) s.Len(entries, 3) // Reversed: newest first @@ -543,7 +543,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Info(gomock.Any()). Return(s.newStreamInfo(0, 0), nil) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.NoError(err) s.Empty(entries) }, @@ -555,7 +555,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Info(gomock.Any()). Return(nil, fmt.Errorf("connection error")) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.Error(err) s.Nil(entries) s.Contains(err.Error(), "get stream info") @@ -571,7 +571,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { OrderedConsumer(gomock.Any(), gomock.Any()). Return(nil, fmt.Errorf("consumer error")) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.Error(err) s.Nil(entries) s.Contains(err.Error(), "create ordered consumer") @@ -592,7 +592,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Fetch(3, gomock.Any()). Return(nil, fmt.Errorf("fetch error")) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.Error(err) s.Nil(entries) s.Contains(err.Error(), "fetch audit entries") @@ -623,7 +623,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Fetch(2, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.NoError(err) s.Len(entries, 1) s.Equal("aaa", entries[0].ID) @@ -652,7 +652,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { Fetch(1, gomock.Any()). Return(mockBatch, nil) }, - validate: func(entries []audit.Entry, err error) { + validateFunc: func(entries []audit.Entry, err error) { s.NoError(err) s.Len(entries, 1) }, @@ -663,7 +663,7 @@ func (s *StreamStorePublicTestSuite) TestListAll() { s.Run(tt.name, func() { tt.setupMock() entries, err := s.store.ListAll(context.Background()) - tt.validate(entries, err) + tt.validateFunc(entries, err) }) } } diff --git a/internal/controller/api/audit/audit_list_public_test.go b/internal/controller/api/audit/audit_list_public_test.go index 17d30cf29..c81b57cb8 100644 --- a/internal/controller/api/audit/audit_list_public_test.go +++ b/internal/controller/api/audit/audit_list_public_test.go @@ -276,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", @@ -299,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", @@ -310,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) @@ -359,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() }) } } @@ -376,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", @@ -400,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", @@ -421,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) @@ -463,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() }) } } From a27d73742eb5e2e5b83584a695b50bac588ed8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 23:02:07 -0700 Subject: [PATCH 14/16] style(audit): align struct field tags in stream store tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/audit/stream_store_public_test.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/audit/stream_store_public_test.go b/internal/audit/stream_store_public_test.go index b1ba05dd6..79ad71f25 100644 --- a/internal/audit/stream_store_public_test.go +++ b/internal/audit/stream_store_public_test.go @@ -167,9 +167,9 @@ func (s *StreamStorePublicTestSuite) TestGet() { data, _ := json.Marshal(entry) tests := []struct { - name string - id string - setupMock func() + name string + id string + setupMock func() validateFunc func(*audit.Entry, error) }{ { @@ -236,10 +236,10 @@ func (s *StreamStorePublicTestSuite) TestList() { data3, _ := json.Marshal(entry3) tests := []struct { - name string - limit int - offset int - setupMock func() + name string + limit int + offset int + setupMock func() validateFunc func([]audit.Entry, int, error) }{ { @@ -494,8 +494,8 @@ func (s *StreamStorePublicTestSuite) TestListAll() { data3, _ := json.Marshal(entry3) tests := []struct { - name string - setupMock func() + name string + setupMock func() validateFunc func([]audit.Entry, error) }{ { From 6fc82f1ca12c1d35a6e812119662a1208d267649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 23:05:15 -0700 Subject: [PATCH 15/16] fix(audit): update integration test config for stream migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test osapi.yaml still had the old KV bucket config (bucket, ttl) instead of stream config (stream, subject, max_age), causing the audit store to not initialize and routes to return 404. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- test/integration/osapi.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 From c64915d0ce17d29850b035bf3c5ee531c72650ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 1 Apr 2026 23:11:16 -0700 Subject: [PATCH 16/16] feat(audit): display trace_id in CLI audit list and get MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Trace ID to audit get output and truncated TRACE ID column to audit list table. Update CLI docs with example output. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_audit_get.go | 3 +++ cmd/client_audit_list.go | 7 +++++++ docs/docs/sidebar/usage/cli/client/audit/get.md | 3 ++- docs/docs/sidebar/usage/cli/client/audit/list.md | 8 ++++---- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/client_audit_get.go b/cmd/client_audit_get.go index ebdc240c5..2de634c1c 100644 --- a/cmd/client_audit_get.go +++ b/cmd/client_audit_get.go @@ -71,6 +71,9 @@ Requires audit:read permission. if entry.OperationID != "" { cli.PrintKV("Operation", entry.OperationID) } + if entry.TraceID != "" { + cli.PrintKV("Trace ID", entry.TraceID) + } }, } diff --git a/cmd/client_audit_list.go b/cmd/client_audit_list.go index 9d1dd77a6..a369f794c 100644 --- a/cmd/client_audit_list.go +++ b/cmd/client_audit_list.go @@ -66,6 +66,11 @@ response status, and duration. Requires audit:read permission. rows := make([][]string, 0, len(resp.Data.Items)) for _, entry := range resp.Data.Items { + traceID := "" + if entry.TraceID != "" { + traceID = entry.TraceID[:16] + "…" + } + rows = append(rows, []string{ entry.ID, entry.Timestamp.Format("2006-01-02 15:04:05"), @@ -74,6 +79,7 @@ response status, and duration. Requires audit:read permission. entry.Path, strconv.Itoa(entry.ResponseCode), strconv.FormatInt(entry.DurationMs, 10) + "ms", + traceID, }) } @@ -88,6 +94,7 @@ response status, and duration. Requires audit:read permission. "PATH", "STATUS", "DURATION", + "TRACE ID", }, Rows: rows, }, 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: