From f63c7c86a993910b980e941fbfababe8c94ea16e Mon Sep 17 00:00:00 2001 From: Brent Rager Date: Fri, 14 Aug 2026 23:03:05 -0400 Subject: [PATCH] Correlate audit events to the trace that caused them, in all five languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit record could not be joined to the request that produced it — no trace_id existed anywhere, in any language. "Who did this, and what was the system doing at the time" was two separate investigations. The ids travel in a NEW WIRE ENVELOPE, deliberately OUTSIDE the hashed payload: {"event":{…sealed event…},"traceId":"…","spanId":"…"} Audit events are hash-chained, and spec/parity-corpus.json is the byte-exact contract both the TS and Rust serializers must reproduce — the file itself warns not to "fix" a divergence by editing hashes. Putting traceId inside the event would have changed EVERY hash, either breaking cross-store verification or forcing a versioned canonicalizer migration. Brent chose the envelope: the chain and every existing fixture stay byte-identical, at the cost of traceId not itself being tamper-evident. That tradeoff is deliberate and worth restating if anyone later proposes moving it inside. In every language the hash is computed BEFORE the envelope exists, so trace context cannot structurally reach the preimage. Evidence the chain is untouched, checked on raw bytes (not grep — RTK fabricated a grep count today): - spec/parity-corpus.json sha256 684135c9… identical to HEAD, 8 expectedHash entries unedited - every hash/canonical/schema source in all five languages byte-identical - both TS and Rust re-run the WHOLE corpus INSIDE an active span and assert byte-exact canonical output and hashes; Go, Python and .NET assert the same over their fixtures in both states OTel stays optional everywhere: TS optional peer dep behind a guarded dynamic import, Rust an off-by-default `otel` cargo feature (verified absent from `cargo tree --no-default-features`), Python a guarded import plus an `[otel]` extra, Go the API only. .NET needed no new package — Activity.Current is enough. Go pins otel v1.35.0 because CI pins Go 1.22 and v1.36+ declare go 1.23; verified by building under GOTOOLCHAIN=go1.22.12. No active span means NO ids — omitted entirely, never an all-zero id or empty string. Mutation-checked in all five languages; the mutation that moves the ids inside the hashed event is caught everywhere. It also exposed a Rust test too weak to notice: it deserialized into AuditEvent first, which silently DROPPED the injected field — now it asserts on raw wire bytes. One Rust mutation survived and is reported rather than hidden: reading the tracing span before Context::current() is redundant with tracing-opentelemetry 0.33. Kept as insurance (same call as @smooai/fetch); its companion mutation proves the other half is load-bearing. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/trace-correlation.md | 42 +++ README.md | 25 ++ dotnet/src/SmooAI.Audit/AuditClient.cs | 29 +- dotnet/src/SmooAI.Audit/Canonical.cs | 29 ++ dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs | 130 +++++++++ go/audit_test.go | 138 ++++++++- go/client.go | 27 +- go/go.mod | 6 +- go/go.sum | 14 + package.json | 10 + pnpm-lock.yaml | 91 ++++++ python/pyproject.toml | 7 +- python/src/smooai_audit/client.py | 34 ++- python/tests/test_audit.py | 93 ++++++ python/uv.lock | 27 +- rust/audit/Cargo.lock | 265 +++++++++++++++++- rust/audit/Cargo.toml | 19 ++ rust/audit/src/client.rs | 24 +- rust/audit/src/envelope.rs | 196 +++++++++++++ rust/audit/src/lib.rs | 2 + rust/audit/tests/trace_envelope.rs | 146 ++++++++++ src/client.spec.ts | 6 +- src/client.ts | 26 +- src/envelope.no-otel.spec.ts | 48 ++++ src/envelope.spec.ts | 188 +++++++++++++ src/envelope.ts | 89 ++++++ src/index.ts | 7 + tsdown.config.ts | 2 +- 28 files changed, 1676 insertions(+), 44 deletions(-) create mode 100644 .changeset/trace-correlation.md create mode 100644 go/go.sum create mode 100644 rust/audit/src/envelope.rs create mode 100644 rust/audit/tests/trace_envelope.rs create mode 100644 src/envelope.no-otel.spec.ts create mode 100644 src/envelope.spec.ts create mode 100644 src/envelope.ts diff --git a/.changeset/trace-correlation.md b/.changeset/trace-correlation.md new file mode 100644 index 0000000..40522b4 --- /dev/null +++ b/.changeset/trace-correlation.md @@ -0,0 +1,42 @@ +--- +"@smooai/audit": minor +--- + +Trace correlation: an emitted audit event now carries the W3C trace context of the +request that caused it, so a row in the audit store can be joined to a trace. + +**The ids ride in the ENVELOPE, never inside the event.** The wire body is now +`{"event":,"spanId":"…","traceId":"…"}`. The bytes under +`"event"` are exactly the bytes that were hashed — unchanged, byte-for-byte, with +or without a trace active — because `hashCurrent` covers canonical-JSON(event +minus `hashCurrent`) and any new event field would invalidate every stored chain +and every fixture in `spec/parity-corpus.json`. The corpus is untouched, and each +language asserts it inside an active span as well as outside one. Both ids are +OMITTED when there is no valid span: never `""`, never an all-zero id. + +TypeScript: `AuditClient.emit(event, trace?)` captures the active context at +emit time behind an optional `@opentelemetry/api` peer dependency. Without it +installed (or without a registered SDK) it is a no-op, not a crash. `buildEnvelope` +/ `currentTraceContext` are exported for consumers on their own transport. + +Rust: the same, behind a new optional `otel` cargo feature (off by default — +the crate does not link OpenTelemetry unless you ask for it). `AuditClient::emit` +uses the ambient span; `emit_with_trace` takes an explicit `TraceContext` that +wins per field. `TraceContext::current()` reads both context homes — a `tracing` +span via tracing-opentelemetry and an OTel-native one — because neither falls +back to the other. + +Go: `AuditClient.Emit(ctx, event)` reads the span context already on the `ctx` it +takes (`trace.SpanContextFromContext(ctx).IsValid()` before touching the ids), via +the OpenTelemetry trace API only — no SDK, no exporter. Pinned to otel v1.35.0, +the newest release whose `go` directive (1.22.0) still builds on the Go 1.22 the +CI matrix pins; v1.36+ declare go 1.23. + +Python: the ids come from the ambient span behind a guarded +`from opentelemetry import trace` import, exposed as the optional `otel` extra +(`pip install smooai-audit[otel]`). Without it installed, correlation is a no-op — +`opentelemetry-api` is never a hard dependency. + +.NET: reads `Activity.Current` — the BCL type the OpenTelemetry .NET SDK itself +populates — so no new package reference. Non-W3C or unstarted activities report +nothing. diff --git a/README.md b/README.md index 3e8fbf3..08dbf40 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,31 @@ Every language exposes the same four things: | `computeEventHash(event)` / `buildHashChain(events)` | The per-org-per-day SHA-256 hash chain. | | `AuditClient` / `emit(event)` | POSTs an event to a configurable ingest endpoint with a bearer token. | +## The wire envelope + +`emit` POSTs the canonical JSON of an **envelope**, not the bare event: + +```json +{ "event": { "…the sealed event…": "…" }, "spanId": "00f067aa0ba902b7", "traceId": "4bf92f3577b34da6a3ce929d0e0e4736" } +``` + +`traceId` / `spanId` are the W3C trace context captured at emit time, so an audit +row can be joined back to the request that caused it. They live in the envelope, +one level ABOVE the event, and never inside it: the hash chain covers +canonical-JSON(event minus `hashCurrent`), so a field added to the event would +change every hash and invalidate every chain already in a store. The bytes under +`"event"` are byte-identical with or without a trace active — every language +asserts the parity corpus inside an active span as well as outside one. + +Both ids are omitted entirely when there is no valid span — never an empty string, +never the all-zero `00000000000000000000000000000000` id an unregistered SDK hands +you. OpenTelemetry is optional everywhere (TypeScript: an optional +`@opentelemetry/api` peer dependency; Rust: the `otel` cargo feature, off by +default; Python: the `otel` extra — `pip install smooai-audit[otel]` — behind a +guarded import; Go: the otel trace API only, no SDK, reading the span context off +the `ctx` you already pass to `Emit`; .NET: `Activity.Current` from the BCL, so no +new package at all), and with it absent the client behaves exactly as it did before. + ## Install **TypeScript / Node** diff --git a/dotnet/src/SmooAI.Audit/AuditClient.cs b/dotnet/src/SmooAI.Audit/AuditClient.cs index a3a96f5..8e683e2 100644 --- a/dotnet/src/SmooAI.Audit/AuditClient.cs +++ b/dotnet/src/SmooAI.Audit/AuditClient.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Net.Http.Headers; using System.Text; @@ -39,14 +40,19 @@ public AuditClient(AuditClientOptions options, HttpClient http) } /// - /// Seal the event (compute + stamp ) and POST its canonical - /// JSON to the endpoint with an Authorization: Bearer header. Throws on a non-success - /// status. ponytail: single POST, no retry/backoff — add it when a real transport SLA demands it. + /// Seal the event (compute + stamp ) and POST the canonical + /// JSON envelope — the sealed event plus the current 's W3C trace ids, when + /// one is active — to the endpoint with an Authorization: Bearer header. Throws on a + /// non-success status. ponytail: single POST, no retry/backoff — add it when a real transport SLA + /// demands it. /// public async Task EmitAsync(AuditEvent @event, CancellationToken cancellationToken = default) { + // Hash FIRST, from the event alone: the trace ids below ride in the envelope and can never + // enter the preimage. var sealedEvent = @event with { HashCurrent = HashChain.ComputeEventHash(@event) }; - var body = Canonical.ToCanonicalJson(sealedEvent); + var (traceId, spanId) = CurrentTraceContext(); + var body = Canonical.ToCanonicalJsonEnvelope(sealedEvent, traceId, spanId); using var request = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint) { @@ -57,4 +63,19 @@ public async Task EmitAsync(AuditEvent @event, CancellationToken cancellationTok using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } + + /// + /// W3C trace ids of the ambient , or (null, null) when there is no + /// activity or it is not W3C-formatted. No package needed — System.Diagnostics.Activity is + /// in the BCL and is what the OpenTelemetry .NET SDK itself populates. + /// + private static (string? TraceId, string? SpanId) CurrentTraceContext() + { + var activity = Activity.Current; + if (activity is null || activity.IdFormat != ActivityIdFormat.W3C || activity.TraceId == default) + { + return (null, null); + } + return (activity.TraceId.ToHexString(), activity.SpanId.ToHexString()); + } } diff --git a/dotnet/src/SmooAI.Audit/Canonical.cs b/dotnet/src/SmooAI.Audit/Canonical.cs index 33c1815..dd30f6b 100644 --- a/dotnet/src/SmooAI.Audit/Canonical.cs +++ b/dotnet/src/SmooAI.Audit/Canonical.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace SmooAI.Audit; @@ -24,6 +25,34 @@ public static class Canonical DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + /// + /// Serialize the transport ENVELOPE to canonical JSON: + /// {"event":{…the sealed event…},"spanId":"…","traceId":"…"}. + /// The trace ids sit one level ABOVE the event, never inside it: they are added AFTER the hash is + /// computed, so the bytes under "event" — and therefore every hash — are identical whether + /// or not a span is active. A null/empty id is omitted entirely (never an all-zero id, never ""). + /// + internal static string ToCanonicalJsonEnvelope(AuditEvent @event, string? traceId, string? spanId) + { + var envelope = new JsonObject + { + ["event"] = JsonNode.Parse(JsonSerializer.Serialize(@event, SerializeOptions)), + }; + if (!string.IsNullOrEmpty(traceId)) + { + envelope["traceId"] = traceId; + } + if (!string.IsNullOrEmpty(spanId)) + { + envelope["spanId"] = spanId; + } + + using var doc = JsonDocument.Parse(envelope.ToJsonString()); + var sb = new StringBuilder(); + Write(sb, doc.RootElement); + return sb.ToString(); + } + /// Serialize an audit event to its canonical JSON string. public static string ToCanonicalJson(AuditEvent @event) { diff --git a/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs b/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs index 857c073..3ca0121 100644 --- a/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs +++ b/dotnet/tests/SmooAI.Audit.Tests/AuditTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.Net; using System.Text.Json; using System.Text.Json.Nodes; using SmooAI.Audit; @@ -64,6 +66,134 @@ public void BuildChainsHashPreviousAcrossEvents() Assert.NotNull(chain[1].HashCurrent); } + // --- Envelope trace correlation --------------------------------------------------- + // traceId/spanId ride OUTSIDE the hashed event, so an Activity must never move a hash. + + private const string TraceIdHex = "4bf92f3577b34da6a3ce929d0e0e4736"; + + /// Captures the POSTed body instead of hitting the network. + private sealed class CapturingHandler : HttpMessageHandler + { + public string Body { get; private set; } = ""; + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Body = await request.Content!.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.Accepted); + } + } + + private static async Task EmitAndCaptureAsync(AuditEvent evt) + { + using var handler = new CapturingHandler(); + using var http = new HttpClient(handler); + var client = new AuditClient(new AuditClientOptions { Endpoint = "http://audit.test/ingest", Token = "t" }, http); + await client.EmitAsync(evt); + return JsonNode.Parse(handler.Body)!.AsObject(); + } + + /// Starts a real W3C Activity with a fixed trace id (listener required, no OTel SDK). + private static (ActivityListener Listener, ActivitySource Source, Activity Activity) StartActivity() + { + var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = static (ref ActivityCreationOptions _) => ActivitySamplingResult.AllData, + }; + ActivitySource.AddActivityListener(listener); + var source = new ActivitySource("SmooAI.Audit.Tests"); + var parent = new ActivityContext(ActivityTraceId.CreateFromString(TraceIdHex), ActivitySpanId.CreateRandom(), ActivityTraceFlags.Recorded); + var activity = source.StartActivity("emit", ActivityKind.Internal, parent)!; + return (listener, source, activity); + } + + private static AuditEvent SampleEvent() => new() + { + Id = "01A", + OrganizationId = "org-1", + ActorType = "user", + ActorId = "user-1", + Action = "crm.contact_created", + Resource = new AuditResource { Type = "crm.contact", Id = "c-1" }, + Outcome = "success", + Metadata = new JsonObject(), + Timestamp = "2026-05-17T12:00:00.000Z", + }; + + [Fact] + public async Task EnvelopeCarriesTraceIdsWhenActivityIsActive() + { + var (listener, source, activity) = StartActivity(); + JsonObject body; + try + { + body = await EmitAndCaptureAsync(SampleEvent()); + } + finally + { + activity.Dispose(); + source.Dispose(); + listener.Dispose(); + } + + Assert.Equal(TraceIdHex, (string?)body["traceId"]); + Assert.Equal(activity.SpanId.ToHexString(), (string?)body["spanId"]); + } + + [Fact] + public async Task EnvelopeOmitsTraceIdsWithoutActivity() + { + Activity.Current = null; + + var body = await EmitAndCaptureAsync(SampleEvent()); + + // Omitted entirely — never an all-zero id, never an empty string. + Assert.False(body.ContainsKey("traceId")); + Assert.False(body.ContainsKey("spanId")); + } + + /// + /// The hash-chain regression gate: every corpus fixture must seal to its committed hash — and the + /// envelope minus the trace ids must be the committed canonical bytes — with or without an Activity. + /// + [Theory] + [MemberData(nameof(Fixtures))] + public async Task CorpusHashUnchangedByActivity(string name, string eventJson, string expectedCanonical, string expectedHash) + { + _ = name; + var evt = JsonSerializer.Deserialize(eventJson, Relaxed)!; + + Activity.Current = null; + var withoutActivity = await EmitAndCaptureAsync(evt); + + var (listener, source, activity) = StartActivity(); + JsonObject withActivity; + try + { + withActivity = await EmitAndCaptureAsync(evt); + } + finally + { + activity.Dispose(); + source.Dispose(); + listener.Dispose(); + } + + foreach (var body in new[] { withoutActivity, withActivity }) + { + var sealedEvent = body["event"]!.DeepClone().AsObject(); + Assert.Equal(expectedHash, (string?)sealedEvent["hashCurrent"]); + + // The event minus its own hash must be the committed canonical bytes. + sealedEvent.Remove("hashCurrent"); + var preimage = JsonSerializer.Deserialize(sealedEvent.ToJsonString(), Relaxed)!; + Assert.Equal(expectedCanonical, Canonical.ToCanonicalJson(preimage)); + } + + Assert.Equal(TraceIdHex, (string?)withActivity["traceId"]); + Assert.False(withoutActivity.ContainsKey("traceId")); + } + private static string CorpusPath() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/go/audit_test.go b/go/audit_test.go index 331f362..1f14c43 100644 --- a/go/audit_test.go +++ b/go/audit_test.go @@ -12,6 +12,8 @@ import ( "os" "path/filepath" "testing" + + "go.opentelemetry.io/otel/trace" ) // corpus mirrors ../spec/parity-corpus.json — the single committed cross-language @@ -175,14 +177,142 @@ func TestEmitPostsCanonicalBytes(t *testing.T) { if gotAuth != "Bearer tok-123" { t.Fatalf("Authorization = %q, want Bearer tok-123", gotAuth) } - // Body must be the sealed event's canonical JSON (includes hashCurrent) and - // its hashCurrent must equal ComputeEventHash of the pre-seal event. + // Body must be the envelope whose "event" is the sealed event's canonical + // JSON (includes hashCurrent), and that hashCurrent must equal + // ComputeEventHash of the pre-seal event. hash, _ := ComputeEventHash(ev) var decoded map[string]any if err := json.Unmarshal([]byte(gotBody), &decoded); err != nil { t.Fatalf("emitted body not JSON: %v", err) } - if decoded["hashCurrent"] != hash { - t.Fatalf("emitted hashCurrent = %v, want %s", decoded["hashCurrent"], hash) + sealed, ok := decoded["event"].(map[string]any) + if !ok { + t.Fatalf("envelope has no event object: %v", decoded) + } + if sealed["hashCurrent"] != hash { + t.Fatalf("emitted hashCurrent = %v, want %s", sealed["hashCurrent"], hash) + } +} + +// tracedContext returns a context carrying a valid, non-recording W3C span +// context — the otel API alone, no SDK, so the test has no exporter to wire up. +func tracedContext(traceID, spanID string) context.Context { + tid, err := trace.TraceIDFromHex(traceID) + if err != nil { + panic(err) + } + sid, err := trace.SpanIDFromHex(spanID) + if err != nil { + panic(err) + } + sc := trace.NewSpanContext(trace.SpanContextConfig{TraceID: tid, SpanID: sid, TraceFlags: trace.FlagsSampled}) + return trace.ContextWithSpanContext(context.Background(), sc) +} + +// captureEmit runs Emit against a throwaway server and returns the decoded body. +func captureEmit(t *testing.T, ctx context.Context, ev AuditEvent) map[string]any { + t.Helper() + var body string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + body = string(b) + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + if err := NewClient(srv.URL, "tok").Emit(ctx, ev); err != nil { + t.Fatalf("Emit: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("emitted body not JSON: %v", err) + } + return decoded +} + +func sampleEvent() AuditEvent { + return AuditEvent{ + ID: "1", OrganizationID: "org-1", ActorType: ActorSystem, ActorID: "sys", + Action: "user.signin", Resource: AuditResource{Type: "user", ID: "u1"}, + Outcome: OutcomeSuccess, Metadata: map[string]any{}, Timestamp: "2026-07-14T00:00:00.000Z", + } +} + +// TestEmitEnvelopeCarriesTraceContext: with an active span the envelope carries +// the W3C ids. +func TestEmitEnvelopeCarriesTraceContext(t *testing.T) { + ctx := tracedContext("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7") + decoded := captureEmit(t, ctx, sampleEvent()) + if decoded["traceId"] != "4bf92f3577b34da6a3ce929d0e0e4736" { + t.Fatalf("traceId = %v, want 4bf92f3577b34da6a3ce929d0e0e4736", decoded["traceId"]) + } + if decoded["spanId"] != "00f067aa0ba902b7" { + t.Fatalf("spanId = %v, want 00f067aa0ba902b7", decoded["spanId"]) + } +} + +// TestEmitOmitsTraceContextWithoutSpan: no span → the keys are ABSENT, not +// zeroed and not empty. +func TestEmitOmitsTraceContextWithoutSpan(t *testing.T) { + decoded := captureEmit(t, context.Background(), sampleEvent()) + if _, ok := decoded["traceId"]; ok { + t.Fatalf("traceId must be omitted without a span, got %v", decoded["traceId"]) + } + if _, ok := decoded["spanId"]; ok { + t.Fatalf("spanId must be omitted without a span, got %v", decoded["spanId"]) + } +} + +// TestEmitHashUnchangedByTraceContext is the hash-chain regression gate: every +// typed corpus fixture must seal to its committed expectedHash whether or not a +// span is active, and the envelope minus the trace ids must be the byte-exact +// canonical preimage-plus-hash the verifier replays. +func TestEmitHashUnchangedByTraceContext(t *testing.T) { + typedFixtures := map[string]bool{ + "minimal_first_of_day": true, + "chained_with_hash_previous": true, + "denied_outcome_with_reason": true, + "vertical_app_ops_with_integers": true, + } + traced := tracedContext("4bf92f3577b34da6a3ce929d0e0e4736", "00f067aa0ba902b7") + for _, f := range loadCorpus(t).Fixtures { + if !typedFixtures[f.Name] { + continue + } + t.Run(f.Name, func(t *testing.T) { + var ev AuditEvent + if err := json.Unmarshal(f.Event, &ev); err != nil { + t.Fatalf("unmarshal into AuditEvent: %v", err) + } + for _, tc := range []struct { + name string + ctx context.Context + }{{"no_span", context.Background()}, {"active_span", traced}} { + t.Run(tc.name, func(t *testing.T) { + decoded := captureEmit(t, tc.ctx, ev) + sealed, ok := decoded["event"].(map[string]any) + if !ok { + t.Fatalf("envelope has no event object: %v", decoded) + } + if sealed["hashCurrent"] != f.ExpectedHash { + t.Fatalf("hashCurrent = %v, want %s", sealed["hashCurrent"], f.ExpectedHash) + } + // The event minus its own hash must be the committed + // canonical bytes — trace ids live outside it. + delete(sealed, "hashCurrent") + reencoded, err := json.Marshal(sealed) + if err != nil { + t.Fatalf("marshal: %v", err) + } + preimage := decodeEvent(t, reencoded) + got, err := CanonicalJSON(preimage) + if err != nil { + t.Fatalf("CanonicalJSON: %v", err) + } + if got != f.ExpectedCanonical { + t.Fatalf("canonical mismatch\n got: %s\nwant: %s", got, f.ExpectedCanonical) + } + }) + } + }) } } diff --git a/go/client.go b/go/client.go index 58ff562..95de327 100644 --- a/go/client.go +++ b/go/client.go @@ -6,10 +6,13 @@ import ( "io" "net/http" "strings" + + "go.opentelemetry.io/otel/trace" ) // AuditClient emits audit events to a configurable ingest endpoint over HTTP. -// Zero external deps — stdlib net/http only. +// Transport is stdlib net/http; the only dependency is the OpenTelemetry trace +// API (no SDK), used to read the ambient span for envelope trace correlation. type AuditClient struct { // Endpoint is the audit ingest URL events are POSTed to. Endpoint string @@ -26,9 +29,13 @@ func NewClient(endpoint, token string) *AuditClient { } // Emit seals the event into the hash chain (computes and attaches HashCurrent) -// and POSTs its canonical JSON to the ingest endpoint with the bearer token. -// The canonical bytes on the wire are the exact preimage-plus-hash the -// verifier replays, so every store agrees byte-for-byte. +// and POSTs the canonical JSON envelope to the ingest endpoint with the bearer +// token: +// +// {"event":{…the sealed event…},"spanId":"…","traceId":"…"} +// +// The bytes under "event" are the exact preimage-plus-hash the verifier +// replays, so every store agrees byte-for-byte; the trace ids ride outside them. func (c *AuditClient) Emit(ctx context.Context, event AuditEvent) error { hash, err := ComputeEventHash(event) if err != nil { @@ -40,7 +47,17 @@ func (c *AuditClient) Emit(ctx context.Context, event AuditEvent) error { if err != nil { return err } - body, err := CanonicalJSON(generic) + // Trace correlation lives in the ENVELOPE, one level ABOVE the event, never + // inside it: the hash above is computed from the event alone, so an active + // span cannot move a single bit of it. The bytes under "event" are identical + // whether or not a trace is active. With no valid span context both fields + // are omitted entirely — never an all-zero id, never an empty string. + envelope := map[string]any{"event": generic} + if sc := trace.SpanContextFromContext(ctx); sc.IsValid() { + envelope["traceId"] = sc.TraceID().String() + envelope["spanId"] = sc.SpanID().String() + } + body, err := CanonicalJSON(envelope) if err != nil { return err } diff --git a/go/go.mod b/go/go.mod index 311ea94..1af6b1e 100644 --- a/go/go.mod +++ b/go/go.mod @@ -1,3 +1,7 @@ module github.com/SmooAI/audit/go -go 1.22 +go 1.22.0 + +require go.opentelemetry.io/otel/trace v1.35.0 + +require go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..b995898 --- /dev/null +++ b/go/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/package.json b/package.json index e31eabe..903d2f1 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,18 @@ "dependencies": { "zod": "^4.0.0" }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + }, "devDependencies": { "@changesets/cli": "^2.28.1", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.10.0", "@smooai/config-typescript": "^1.0.16", "@types/node": "^22.13.10", "oxfmt": "^0.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f0c660..34f4050 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,12 @@ importers: '@changesets/cli': specifier: ^2.28.1 version: 2.31.0(@types/node@22.20.1) + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/sdk-trace-node': + specifier: ^2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.1) '@smooai/config-typescript': specifier: ^1.0.16 version: 1.0.18(typescript@5.9.3) @@ -303,6 +309,50 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@2.10.0': + resolution: {integrity: sha512-bvyMcgLEkozzSzpEEEo1OMoeQ97bxj6Qs2uN3mPrSdDvObMI1myffD/BPqcLlzZO9//d1SqQA/WPw7Cz2AiqhA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.10.0': + resolution: {integrity: sha512-GZK/G6oZyBLGlH1pUgeDch7D91KoHd2uotUGIkWCPi9GI5T9X0p4L7nNAMDR1BQjkRYoDqo+ddfVx9t5Uhys+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} @@ -1723,6 +1773,47 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.139.0': {} '@oxfmt/darwin-arm64@0.28.0': diff --git a/python/pyproject.toml b/python/pyproject.toml index 6526b99..92390a7 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -16,6 +16,11 @@ classifiers = [ ] keywords = ["audit", "audit-log", "tamper-evident", "hash-chain", "smooai"] +[project.optional-dependencies] +# Trace correlation: with this installed, emit() stamps the active span's W3C +# traceId/spanId onto the envelope. The library works without it (guarded import). +otel = ["opentelemetry-api>=1.20.0"] + [project.urls] Homepage = "https://github.com/SmooAI/audit" Repository = "https://github.com/SmooAI/audit" @@ -26,7 +31,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = ["poethepoet>=0.29.0", "pytest>=8.3.0", "ruff>=0.11.6,<0.12", "basedpyright>=1.29.2"] +dev = ["poethepoet>=0.29.0", "pytest>=8.3.0", "ruff>=0.11.6,<0.12", "basedpyright>=1.29.2", "opentelemetry-api>=1.20.0"] [tool.poe.tasks.install-dev] cmd = "uv sync --locked --group dev" diff --git a/python/src/smooai_audit/client.py b/python/src/smooai_audit/client.py index 821a833..89ff012 100644 --- a/python/src/smooai_audit/client.py +++ b/python/src/smooai_audit/client.py @@ -18,6 +18,26 @@ from .hash import compute_event_hash from .schema import AuditEvent +try: # OpenTelemetry is an OPTIONAL extra (``pip install smooai-audit[otel]``). + from opentelemetry import trace as _otel_trace +except ImportError: # pragma: no cover — exercised by the no-otel install path + _otel_trace = None + + +def _trace_envelope() -> dict[str, str]: + """Current W3C trace ids, or ``{}`` when otel is absent or no span is active. + + These ride in the ENVELOPE only — one level ABOVE the event, never inside it + — so a trace context can never change an event's ``hashCurrent``. An invalid + (all-zero) span context yields no keys at all rather than zeroed or empty ids. + """ + if _otel_trace is None: + return {} + context = _otel_trace.get_current_span().get_span_context() + if not context.is_valid: + return {} + return {"traceId": format(context.trace_id, "032x"), "spanId": format(context.span_id, "016x")} + @dataclass class AuditClientOptions: @@ -42,13 +62,19 @@ def __init__(self, options: AuditClientOptions) -> None: self._options = options def emit(self, event: AuditEvent) -> None: - """Seal ``event`` (stamp ``hashCurrent`` if absent) and POST its canonical - JSON with ``Authorization: Bearer ``. Swallows errors unless - ``swallow_errors`` is False.""" + """Seal ``event`` (stamp ``hashCurrent`` if absent) and POST the canonical + JSON envelope with ``Authorization: Bearer ``:: + + {"event": {…the sealed event…}, "spanId": "…", "traceId": "…"} + + The bytes under ``event`` are what the hash covers; the active span's + ids ride outside them (omitted entirely when there is no span). Swallows + errors unless ``swallow_errors`` is False.""" try: if not event.hash_current: event = event.model_copy(update={"hash_current": compute_event_hash(event)}) - body = canonical_json(event).encode("utf-8") + envelope = {"event": event.model_dump(by_alias=True, exclude_unset=True, mode="json")} | _trace_envelope() + body = canonical_json(envelope).encode("utf-8") request = urllib.request.Request( self._options.endpoint, data=body, diff --git a/python/tests/test_audit.py b/python/tests/test_audit.py index 7a833ad..0b96954 100644 --- a/python/tests/test_audit.py +++ b/python/tests/test_audit.py @@ -111,3 +111,96 @@ def test_emit_swallows_transport_errors_by_default() -> None: ) client.emit(event) # must not raise assert len(captured) == 1 + + +# --- Envelope trace correlation ------------------------------------------------- +# traceId/spanId ride OUTSIDE the hashed event, so an active span must never move +# a hash. otel is an optional extra: skip if it is not installed. + +_TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +_SPAN_ID = "00f067aa0ba902b7" + + +class _FakeResponse: + status = 202 + headers: dict[str, str] = {} # noqa: RUF012 — test stub + + def __enter__(self) -> _FakeResponse: + return self + + def __exit__(self, *_: object) -> None: + return None + + +def _emit_capture(monkeypatch: pytest.MonkeyPatch, event: AuditEvent) -> dict[str, Any]: + """Emit ``event`` through a stubbed transport and return the decoded body.""" + captured: dict[str, Any] = {} + + def fake_urlopen(request: Any, timeout: float = 0) -> _FakeResponse: # noqa: ARG001 + captured.update(json.loads(request.data.decode("utf-8"))) + return _FakeResponse() + + monkeypatch.setattr("smooai_audit.client.urllib.request.urlopen", fake_urlopen) + client = AuditClient(AuditClientOptions(endpoint="http://audit.test/ingest", token="t", swallow_errors=False)) + client.emit(event) + return captured + + +def _active_span() -> Any: + """A valid non-recording span from the otel API alone (no SDK/exporter).""" + trace = pytest.importorskip("opentelemetry.trace") + span_context = trace.SpanContext( + trace_id=int(_TRACE_ID, 16), + span_id=int(_SPAN_ID, 16), + is_remote=False, + trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED), + ) + return trace.use_span(trace.NonRecordingSpan(span_context), end_on_exit=False) + + +def _sample_event() -> AuditEvent: + return AuditEvent( + id="e-1", + organization_id="org-1", + actor_type="user", + actor_id="u-1", + action="crm.contact_created", + resource={"type": "crm.contact", "id": "c-1"}, + outcome="success", + metadata={}, + timestamp="2026-05-17T12:00:00.000Z", + ) + + +def test_envelope_carries_trace_ids_when_span_active(monkeypatch: pytest.MonkeyPatch) -> None: + with _active_span(): + body = _emit_capture(monkeypatch, _sample_event()) + assert body["traceId"] == _TRACE_ID + assert body["spanId"] == _SPAN_ID + + +def test_envelope_omits_trace_ids_without_span(monkeypatch: pytest.MonkeyPatch) -> None: + body = _emit_capture(monkeypatch, _sample_event()) + assert "traceId" not in body # omitted entirely — never all-zero, never "" + assert "spanId" not in body + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=_FIXTURE_IDS) +def test_corpus_hash_unchanged_under_active_span(monkeypatch: pytest.MonkeyPatch, fixture: dict[str, Any]) -> None: + """The hash-chain regression gate: every corpus fixture must seal to its + committed hash — and the envelope minus the trace ids must be the committed + canonical bytes — whether or not a span is active.""" + event = AuditEvent.model_validate(fixture["event"]) + + without_span = _emit_capture(monkeypatch, event) + with _active_span(): + with_span = _emit_capture(monkeypatch, event) + + for body in (without_span, with_span): + sealed = body["event"] + assert sealed["hashCurrent"] == fixture["expectedHash"] + preimage = {k: v for k, v in sealed.items() if k != "hashCurrent"} + assert canonical_json(preimage) == fixture["expectedCanonical"] + + assert with_span["traceId"] == _TRACE_ID + assert "traceId" not in without_span diff --git a/python/uv.lock b/python/uv.lock index 2dc07b4..b25c2da 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [[package]] @@ -57,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -291,20 +303,31 @@ dependencies = [ { name = "pydantic" }, ] +[package.optional-dependencies] +otel = [ + { name = "opentelemetry-api" }, +] + [package.dev-dependencies] dev = [ { name = "basedpyright" }, + { name = "opentelemetry-api" }, { name = "poethepoet" }, { name = "pytest" }, { name = "ruff" }, ] [package.metadata] -requires-dist = [{ name = "pydantic", specifier = ">=2.7.0" }] +requires-dist = [ + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20.0" }, + { name = "pydantic", specifier = ">=2.7.0" }, +] +provides-extras = ["otel"] [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.29.2" }, + { name = "opentelemetry-api", specifier = ">=1.20.0" }, { name = "poethepoet", specifier = ">=0.29.0" }, { name = "pytest", specifier = ">=8.3.0" }, { name = "ruff", specifier = ">=0.11.6,<0.12" }, diff --git a/rust/audit/Cargo.lock b/rust/audit/Cargo.lock index e25d975..d674c19 100644 --- a/rust/audit/Cargo.lock +++ b/rust/audit/Cargo.lock @@ -71,7 +71,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -159,6 +159,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.32" @@ -172,6 +200,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-macro", + "futures-sink", "futures-task", "pin-project-lite", "slab", @@ -200,6 +230,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -209,8 +251,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -454,6 +496,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -495,12 +543,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror 2.0.18", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -513,6 +600,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -522,6 +615,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -560,7 +662,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -596,12 +698,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" @@ -610,7 +728,26 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -625,7 +762,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -800,6 +937,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -822,11 +968,16 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" name = "smooai-audit" version = "0.0.0" dependencies = [ + "opentelemetry", + "opentelemetry_sdk", "reqwest", "serde", "serde_json", "sha2", "thiserror 1.0.69", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", ] [[package]] @@ -922,6 +1073,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1023,9 +1183,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1033,6 +1205,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26" +dependencies = [ + "js-sys", + "opentelemetry", + "tracing", + "tracing-core", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -1077,6 +1289,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -1098,6 +1316,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1270,6 +1497,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" @@ -1299,6 +1532,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/rust/audit/Cargo.toml b/rust/audit/Cargo.toml index c98936d..bde5169 100644 --- a/rust/audit/Cargo.toml +++ b/rust/audit/Cargo.toml @@ -17,6 +17,11 @@ readme = "README.md" # `default-features = false` to drop the reqwest + async-runtime pull entirely. default = ["client"] client = ["dep:reqwest"] +# W3C trace correlation on the emit ENVELOPE (never inside the hashed event). +# Off by default: an OSS audit SDK must not force OpenTelemetry on anyone. With +# the feature off, `TraceContext::current()` is an empty-struct no-op and nothing +# here links OTel at all. +otel = ["dep:opentelemetry", "dep:tracing", "dep:tracing-opentelemetry"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -28,3 +33,17 @@ thiserror = "1" # not a re-serialization. reqwest brings its own async runtime. Optional so # schema-only consumers avoid it (see the `client` feature above). reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"], optional = true } +# Trace correlation, OPTIONAL by design (see the `otel` feature above). +opentelemetry = { version = "0.32", optional = true, default-features = false, features = ["trace"] } +# Needed to read a `tracing` span's OTel context. Every SmooAI Rust service +# carries its span through a tracing-opentelemetry layer, and +# `opentelemetry::Context::current()` does NOT necessarily see those — reading +# only the OTel-native context would make the feature a no-op in production. +tracing = { version = "0.1", optional = true } +tracing-opentelemetry = { version = "0.33", optional = true, default-features = false } + +[dev-dependencies] +# The correlation tests drive a real tracer so the assertion is on the ids that +# actually reach the envelope, not on our own builder. +opentelemetry_sdk = { version = "0.32", features = ["trace"] } +tracing-subscriber = { version = "0.3", features = ["registry"] } diff --git a/rust/audit/src/client.rs b/rust/audit/src/client.rs index 6040edf..91675c5 100644 --- a/rust/audit/src/client.rs +++ b/rust/audit/src/client.rs @@ -3,7 +3,7 @@ //! Seals an event (computes `hashCurrent`) and POSTs its canonical JSON to the //! ingest endpoint with `Authorization: Bearer `. -use crate::canonical::canonical_json; +use crate::envelope::{envelope_json, TraceContext}; use crate::error::AuditError; use crate::schema::AuditEvent; @@ -34,12 +34,24 @@ impl AuditClient { } } - /// Seal `event` (compute its `hashCurrent`) and POST its canonical JSON to - /// the ingest endpoint. Returns [`AuditError::Status`] on a non-2xx response. + /// Seal `event` (compute its `hashCurrent`) and POST its canonical JSON + /// envelope to the ingest endpoint, carrying the active W3C trace context so + /// the event can be tied back to the request that caused it. Returns + /// [`AuditError::Status`] on a non-2xx response. pub async fn emit(&self, event: &AuditEvent) -> Result<(), AuditError> { - // Send the sealed event's canonical bytes, NOT a re-serialization — the - // wire body must be the exact canonical form so the chain is replayable. - let body = canonical_json(&serde_json::to_value(event.sealed())?); + self.emit_with_trace(event, None).await + } + + /// [`AuditClient::emit`] with an explicit trace context, which wins over the + /// ambient span per field — a caller that knows the trace (a queue consumer + /// replaying a producer's context, say) is more authoritative than whatever + /// span happens to be active at emit time. + pub async fn emit_with_trace(&self, event: &AuditEvent, trace: Option) -> Result<(), AuditError> { + // Send the sealed event's canonical bytes under `event`, NOT a + // re-serialization — the wire body must be the exact canonical form so + // the chain is replayable. Trace ids ride BESIDE it, never inside, so + // they cannot perturb a hash. + let body = envelope_json(event, trace)?; let response = self .http .post(&self.endpoint) diff --git a/rust/audit/src/envelope.rs b/rust/audit/src/envelope.rs new file mode 100644 index 0000000..e8693e8 --- /dev/null +++ b/rust/audit/src/envelope.rs @@ -0,0 +1,196 @@ +//! The wire envelope and the trace ids it carries. +//! +//! # Why the ids are HERE and not on the event +//! +//! `hashCurrent = SHA-256(canonical-JSON(event minus hashCurrent))`. Any field +//! added to [`AuditEvent`] changes every hash, invalidating every chain already +//! in a store and every fixture in `spec/parity-corpus.json`. So trace +//! correlation rides one level up, in the envelope: +//! +//! ```text +//! {"event":{…the exact hashed bytes…},"spanId":"…","traceId":"…"} +//! ``` +//! +//! The bytes under `"event"` are byte-identical whether or not a trace is +//! active — which is what the parity gate asserts. + +use serde_json::{Map, Value}; + +use crate::canonical::canonical_json; +use crate::error::AuditError; +use crate::schema::AuditEvent; + +/// Trace correlation ids carried alongside an event. +/// +/// Both are optional and are OMITTED — never `""`, never the all-zero +/// `00000000000000000000000000000000` id — when there is nothing real to +/// report. An all-zero id is what an unregistered tracer hands you, and storing +/// it is worse than storing nothing: it looks like a correlation id and joins +/// every uncorrelated event to itself. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TraceContext { + pub trace_id: Option, + pub span_id: Option, +} + +impl TraceContext { + /// The active W3C trace context, or an empty one when there is none. + /// + /// Two guards, each for a reason: + /// + /// 1. **Optional feature.** With `otel` off this returns empty and the crate + /// does not link OpenTelemetry — an OSS audit SDK must not force it on + /// anyone. + /// 2. **Valid span contexts only.** An unregistered `TracerProvider` yields + /// `INVALID_SPAN_CONTEXT` (all-zero ids); reporting those poisons the + /// audit trail with a correlation id that correlates nothing. + #[cfg(feature = "otel")] + pub fn current() -> Self { + use opentelemetry::trace::TraceContextExt as _; + use tracing_opentelemetry::OpenTelemetrySpanExt as _; + + // TWO context homes, and neither falls back to the other. Every SmooAI + // Rust service carries its span as a `tracing` span picked up by a + // tracing-opentelemetry layer, reachable ONLY through + // `Span::current().context()`; `opentelemetry::Context::current()` sees + // just OTel-native spans. Reading only the latter makes this a silent + // no-op in production while passing a test that happens to create an + // OTel-native span (the bug `@smooai/fetch` hit). + let cx = tracing::Span::current().context(); + let cx = if cx.span().span_context().is_valid() { + cx + } else { + opentelemetry::Context::current() + }; + + let span_context = cx.span().span_context().clone(); + if !span_context.is_valid() { + return Self::default(); + } + Self { + trace_id: Some(span_context.trace_id().to_string()), + span_id: Some(span_context.span_id().to_string()), + } + } + + /// No-op when the `otel` feature is off — the crate does not link OpenTelemetry. + #[cfg(not(feature = "otel"))] + pub fn current() -> Self { + Self::default() + } + + /// `self` wins over `other` per field; empty strings count as absent on both + /// sides, so they can never reach the wire. Used to let a caller-supplied + /// context override the ambient span. + fn or(self, other: Self) -> Self { + let pick = |a: Option, b: Option| a.filter(|s| !s.is_empty()).or(b.filter(|s| !s.is_empty())); + Self { + trace_id: pick(self.trace_id, other.trace_id), + span_id: pick(self.span_id, other.span_id), + } + } +} + +/// Canonical JSON of the envelope around `event` — the exact bytes POSTed to the +/// ingest endpoint. Seals `event` (stamping `hashCurrent`) and attaches the +/// active trace context, with `override_trace` winning per field. +pub fn envelope_json(event: &AuditEvent, override_trace: Option) -> Result { + let trace = override_trace.unwrap_or_default().or(TraceContext::current()); + + let mut envelope = Map::new(); + envelope.insert("event".to_string(), serde_json::to_value(event.sealed())?); + if let Some(trace_id) = trace.trace_id { + envelope.insert("traceId".to_string(), Value::String(trace_id)); + } + if let Some(span_id) = trace.span_id { + envelope.insert("spanId".to_string(), Value::String(span_id)); + } + Ok(canonical_json(&Value::Object(envelope))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{ActorType, AuditResource, Outcome}; + + fn event() -> AuditEvent { + AuditEvent { + id: "01HXXXXXXXXXXXXXXXXXXXXXXX".into(), + organization_id: "org-1".into(), + actor_type: ActorType::User, + actor_id: "user-1".into(), + actor_email: None, + action: "crm.contact_created".into(), + resource: AuditResource { + type_: "crm.contact".into(), + id: "c-1".into(), + }, + outcome: Outcome::Success, + reason: None, + session_id: None, + conversation_id: None, + ip_address: None, + user_agent: None, + geo_country: None, + diff: None, + metadata: Default::default(), + timestamp: "2026-05-17T12:00:00.000Z".into(), + hash_previous: None, + hash_current: None, + } + } + + /// With no tracer registered (and, with the feature off, no OTel at all) the + /// ids are absent — not all-zero, not empty. + #[test] + fn omits_ids_when_there_is_no_span() { + let body = envelope_json(&event(), None).unwrap(); + assert!(!body.contains("traceId"), "no traceId key: {body}"); + assert!(!body.contains("spanId"), "no spanId key: {body}"); + assert!(!body.contains("00000000"), "never an all-zero id: {body}"); + assert!(!body.contains("\"\""), "never an empty string: {body}"); + } + + #[test] + fn caller_supplied_ids_are_carried_beside_the_event() { + let trace = TraceContext { + trace_id: Some("11111111111111111111111111111111".into()), + span_id: Some("2222222222222222".into()), + }; + let body = envelope_json(&event(), Some(trace)).unwrap(); + assert!(body.contains("\"traceId\":\"11111111111111111111111111111111\""), "{body}"); + assert!(body.contains("\"spanId\":\"2222222222222222\""), "{body}"); + // Beside, never inside: the event object ends before the ids begin. + let parsed: Value = serde_json::from_str(&body).unwrap(); + assert!(parsed["event"].get("traceId").is_none()); + assert!(parsed["event"].get("spanId").is_none()); + } + + #[test] + fn caller_supplied_empty_strings_are_treated_as_absent() { + let trace = TraceContext { + trace_id: Some(String::new()), + span_id: Some(String::new()), + }; + let body = envelope_json(&event(), Some(trace)).unwrap(); + assert!(!body.contains("traceId"), "{body}"); + assert!(!body.contains("spanId"), "{body}"); + } + + /// The hashed bytes are the event's own: recomputing from what goes over the + /// wire reproduces the stamped hash, which it could not if the trace ids had + /// landed inside the event. + #[test] + fn the_hashed_bytes_are_unchanged_by_the_envelope() { + let event = event(); + let trace = TraceContext { + trace_id: Some("11111111111111111111111111111111".into()), + span_id: Some("2222222222222222".into()), + }; + let body = envelope_json(&event, Some(trace)).unwrap(); + let parsed: Value = serde_json::from_str(&body).unwrap(); + let round_tripped: AuditEvent = serde_json::from_value(parsed["event"].clone()).unwrap(); + assert_eq!(round_tripped.hash_current.as_deref(), Some(event.compute_hash().as_str())); + assert_eq!(round_tripped.canonical(), event.canonical()); + } +} diff --git a/rust/audit/src/lib.rs b/rust/audit/src/lib.rs index 4039bea..2bbf7c6 100644 --- a/rust/audit/src/lib.rs +++ b/rust/audit/src/lib.rs @@ -10,6 +10,7 @@ pub mod canonical; #[cfg(feature = "client")] pub mod client; +pub mod envelope; pub mod error; pub mod hash; pub mod schema; @@ -17,6 +18,7 @@ pub mod schema; pub use crate::canonical::canonical_json; #[cfg(feature = "client")] pub use crate::client::{AuditClient, AuditClientOptions}; +pub use crate::envelope::{envelope_json, TraceContext}; pub use crate::error::AuditError; pub use crate::hash::{build_hash_chain, compute_event_hash}; pub use crate::schema::{actions, is_namespaced_action, ActorType, AuditDiff, AuditEvent, AuditResource, Outcome, AUDIT_ACTIONS}; diff --git a/rust/audit/tests/trace_envelope.rs b/rust/audit/tests/trace_envelope.rs new file mode 100644 index 0000000..7e2a4dd --- /dev/null +++ b/rust/audit/tests/trace_envelope.rs @@ -0,0 +1,146 @@ +//! Trace correlation on the emit envelope (feature `otel`). +//! +//! The gap these guard: an audit event could not be tied to the request that +//! caused it — there was no trace id anywhere, in any language. +//! +//! The load-bearing test is [`parity_corpus_hashes_are_unchanged_inside_a_span`]: +//! the ids ride in the ENVELOPE, one level above the event, precisely so that +//! having a trace active cannot move a single hash. If it can, the change is +//! wrong and every stored chain is invalid. +#![cfg(feature = "otel")] + +use opentelemetry::trace::{TraceContextExt as _, TracerProvider as _}; +use opentelemetry_sdk::trace::SdkTracerProvider; +use serde::Deserialize; +use serde_json::Value; +use tracing_opentelemetry::OpenTelemetrySpanExt as _; +use tracing_subscriber::layer::SubscriberExt as _; + +use smooai_audit::{canonical_json, compute_event_hash, envelope_json, AuditEvent, TraceContext}; + +#[derive(Deserialize)] +struct Corpus { + fixtures: Vec, +} + +#[derive(Deserialize)] +struct Fixture { + name: String, + event: Value, + #[serde(rename = "expectedCanonical")] + expected_canonical: String, + #[serde(rename = "expectedHash")] + expected_hash: String, +} + +fn corpus() -> Corpus { + let raw = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/parity-corpus.json")); + serde_json::from_str(raw).expect("parity corpus parses") +} + +fn event() -> AuditEvent { + serde_json::from_value(corpus().fixtures[0].event.clone()).expect("first fixture is an AuditEvent") +} + +/// Run `f` inside the production shape: a `tracing` span picked up by a +/// tracing-opentelemetry layer — NOT an OTel-native span. `@smooai/fetch` shipped +/// the native form in a test first and it passed against an implementation that +/// read only `Context::current()`, which sees nothing in any real SmooAI service. +fn in_span(f: impl FnOnce(&str, &str) -> T) -> T { + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("audit-envelope-test"); + let subscriber = tracing_subscriber::registry().with(tracing_opentelemetry::layer().with_tracer(tracer)); + let _sub = tracing::subscriber::set_default(subscriber); + + let span = tracing::info_span!("caller"); + let _entered = span.enter(); + let span_context = span.context().span().span_context().clone(); + assert!(span_context.is_valid(), "test setup: the span context must be valid"); + f(&span_context.trace_id().to_string(), &span_context.span_id().to_string()) +} + +#[test] +fn the_envelope_carries_the_active_span_ids() { + let (body, trace_id, span_id) = in_span(|trace_id, span_id| (envelope_json(&event(), None).unwrap(), trace_id.to_string(), span_id.to_string())); + + let parsed: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["traceId"], Value::String(trace_id)); + assert_eq!(parsed["spanId"], Value::String(span_id)); + // Beside the event, never inside it. + assert!(parsed["event"].get("traceId").is_none()); + assert!(parsed["event"].get("spanId").is_none()); +} + +#[test] +fn current_reads_the_active_span_and_nothing_outside_one() { + assert_eq!(TraceContext::current(), TraceContext::default(), "no span → no ids"); + + let captured = in_span(|trace_id, span_id| { + let current = TraceContext::current(); + assert_eq!(current.trace_id.as_deref(), Some(trace_id)); + assert_eq!(current.span_id.as_deref(), Some(span_id)); + current + }); + assert!(captured.trace_id.is_some()); +} + +/// The other context home: an OTel-NATIVE span, with no `tracing` span in sight. +/// `TraceContext::current()` reads both because neither falls back to the other — +/// a consumer on the plain OpenTelemetry API is as valid as a SmooAI service on +/// `tracing`. +#[test] +fn an_otel_native_span_is_read_too() { + use opentelemetry::trace::Tracer as _; + + let provider = SdkTracerProvider::builder().build(); + let tracer = provider.tracer("audit-envelope-test"); + tracer.in_span("caller", |cx| { + let expected = cx.span().span_context().clone(); + let current = TraceContext::current(); + assert_eq!(current.trace_id.as_deref(), Some(expected.trace_id().to_string().as_str())); + assert_eq!(current.span_id.as_deref(), Some(expected.span_id().to_string().as_str())); + }); +} + +#[test] +fn a_caller_supplied_context_wins_over_the_active_span() { + let supplied = TraceContext { + trace_id: Some("11111111111111111111111111111111".into()), + span_id: Some("2222222222222222".into()), + }; + let body = in_span(|_, _| envelope_json(&event(), Some(supplied.clone())).unwrap()); + + let parsed: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["traceId"], Value::String(supplied.trace_id.unwrap())); + assert_eq!(parsed["spanId"], Value::String(supplied.span_id.unwrap())); +} + +/// The whole point. Every fixture must produce byte-exact canonical JSON and the +/// same hash with a trace context active as without one. +#[test] +fn parity_corpus_hashes_are_unchanged_inside_a_span() { + in_span(|_, _| { + for f in corpus().fixtures { + assert_eq!(canonical_json(&f.event), f.expected_canonical, "canonical mismatch [{}]", f.name); + assert_eq!(compute_event_hash(&f.event), f.expected_hash, "hash mismatch [{}]", f.name); + + let event: AuditEvent = serde_json::from_value(f.event.clone()).unwrap(); + assert_eq!(event.canonical(), f.expected_canonical, "schema canonical mismatch [{}]", f.name); + assert_eq!(event.compute_hash(), f.expected_hash, "schema hash mismatch [{}]", f.name); + + // …and through the wire path. Asserted on the RAW bytes, not on a + // deserialized `AuditEvent`: serde drops unknown fields, so a trace + // id that leaked into the event would round-trip away and this test + // would pass while the wire was wrong. + let body = envelope_json(&event, None).unwrap(); + let parsed: Value = serde_json::from_str(&body).unwrap(); + let mut wire_event = parsed["event"].as_object().expect("event is an object").clone(); + let stamped = wire_event.remove("hashCurrent"); + let for_hash = Value::Object(wire_event); + assert_eq!(canonical_json(&for_hash), f.expected_canonical, "envelope canonical mismatch [{}]", f.name); + assert_eq!(compute_event_hash(&for_hash), f.expected_hash, "envelope hash mismatch [{}]", f.name); + assert_eq!(stamped, Some(Value::String(f.expected_hash.clone())), "stamped hash mismatch [{}]", f.name); + assert!(parsed.get("traceId").is_some(), "the span's id rides OUTSIDE the event [{}]", f.name); + } + }); +} diff --git a/src/client.spec.ts b/src/client.spec.ts index 0098e09..3cbb328 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -18,7 +18,7 @@ const event = { const okResponse = () => new Response(null, { status: 200 }); describe("AuditClient.emit", () => { - it("POSTs canonical JSON of the sealed event with a Bearer token", async () => { + it("POSTs the canonical JSON envelope around the sealed event with a Bearer token", async () => { const fetchImpl = vi.fn().mockResolvedValue(okResponse()); const client = new AuditClient({ endpoint: "https://audit.example/events", @@ -34,8 +34,10 @@ describe("AuditClient.emit", () => { expect(init.method).toBe("POST"); expect((init.headers as Record).authorization).toBe("Bearer tok-123"); + // The event rides inside the envelope; trace ids (absent here — no span) ride + // beside it, never inside, so the hashed bytes are untouched. const sealed = { ...event, hashCurrent: computeEventHash(event) }; - expect(init.body).toBe(canonicalJson(sealed)); + expect(init.body).toBe(canonicalJson({ event: sealed })); }); it("retries on HTTP 5xx then succeeds", async () => { diff --git a/src/client.ts b/src/client.ts index cee57f6..4d00309 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,4 @@ -import { canonicalJson } from "./canonical"; +import { buildEnvelope, envelopeJson, type TraceContext } from "./envelope"; import { computeEventHash } from "./hash"; import type { AuditEvent } from "./schema"; @@ -23,8 +23,11 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout /** * Client that emits audit events to a configurable ingest endpoint over HTTPS * with a bearer token. It stamps each event's `hashCurrent` (the canonical-JSON - * SHA-256) and POSTs the canonical JSON body — the wire bytes are exactly what - * every language SDK produces, so the server can persist without re-serializing. + * SHA-256) and POSTs the canonical JSON of an {@link AuditEnvelope} — + * `{"event":,"spanId":…,"traceId":…}`. The bytes under + * `"event"` are exactly what every language SDK produces for that event, so the + * server can persist them without re-serializing, and the trace ids ride + * OUTSIDE them so they cannot perturb a single hash. * * Retries transient failures (network errors and HTTP 5xx) with exponential * backoff; 4xx responses are surfaced immediately (they will not succeed on @@ -46,14 +49,21 @@ export class AuditClient { } /** - * Seal `event` with its `hashCurrent` and POST the canonical JSON to the - * ingest endpoint. Resolves on 2xx; throws on non-transient (4xx) responses - * and after retries are exhausted on transient failures. + * Seal `event` with its `hashCurrent` and POST the canonical JSON of its + * envelope to the ingest endpoint. Resolves on 2xx; throws on non-transient + * (4xx) responses and after retries are exhausted on transient failures. + * + * The active W3C trace context is captured here, at emit time, and carried in + * the envelope so an event can be tied back to the request that caused it. + * `trace` overrides the ambient span; both are omitted when there is nothing + * valid to report. */ - async emit(event: Omit): Promise { + async emit(event: Omit, trace?: TraceContext): Promise { const hashCurrent = computeEventHash(event); const sealed: SealedAuditEvent = { ...event, hashCurrent }; - const body = canonicalJson(sealed); + // Built once, outside the retry loop: a retried POST must carry the SAME + // bytes, since the ingest endpoint dedupes on the event's hash. + const body = envelopeJson(await buildEnvelope(sealed, trace)); const headers: Record = { "content-type": "application/json", authorization: `Bearer ${this.token}`, diff --git a/src/envelope.no-otel.spec.ts b/src/envelope.no-otel.spec.ts new file mode 100644 index 0000000..19c6ae4 --- /dev/null +++ b/src/envelope.no-otel.spec.ts @@ -0,0 +1,48 @@ +import { expect, it, vi } from "vitest"; +import { computeEventHash } from "./hash"; + +/** + * `@opentelemetry/api` is an OPTIONAL peer dependency. Simulate it being absent — + * the import rejects exactly as it does when the package is not installed — and + * assert the client still emits: no trace ids, no crash, same hash. + */ +vi.mock("@opentelemetry/api", () => { + throw new Error("Cannot find module '@opentelemetry/api'"); +}); + +const { AuditClient } = await import("./client"); +const { currentTraceContext } = await import("./envelope"); + +const event = { + id: "01HXXXXXXXXXXXXXXXXXXXXXXX", + organizationId: "org-1", + actorType: "user" as const, + actorId: "user-1", + action: "crm.contact_created", + resource: { type: "crm.contact", id: "c-1" }, + outcome: "success" as const, + metadata: {}, + timestamp: "2026-05-17T12:00:00.000Z", +}; + +it("emits without trace ids when @opentelemetry/api is not installed", async () => { + // Prove the simulated absence is actually in effect for this module registry — + // otherwise this would pass for the wrong reason (merely no active span). + await expect(import("@opentelemetry/api")).rejects.toThrow(); + + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const client = new AuditClient({ + endpoint: "https://audit.example/events", + token: "t", + fetchImpl, + }); + + await expect(client.emit(event)).resolves.toBeUndefined(); + + expect(await currentTraceContext()).toEqual({}); + const body = fetchImpl.mock.calls[0]![1].body as string; + const envelope = JSON.parse(body); + expect("traceId" in envelope).toBe(false); + expect("spanId" in envelope).toBe(false); + expect(envelope.event.hashCurrent).toBe(computeEventHash(event)); +}); diff --git a/src/envelope.spec.ts b/src/envelope.spec.ts new file mode 100644 index 0000000..f688601 --- /dev/null +++ b/src/envelope.spec.ts @@ -0,0 +1,188 @@ +import { readFileSync } from "node:fs"; +import { context, INVALID_SPAN_CONTEXT, trace } from "@opentelemetry/api"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AuditClient } from "./client"; +import { buildEnvelope, currentTraceContext, type AuditEnvelope } from "./envelope"; +import { computeEventHash } from "./hash"; + +/** + * Trace correlation on the emit envelope. + * + * The load-bearing assertion is the LAST describe block: attaching trace ids + * must not move a single hash. They ride in the envelope, one level above the + * event, precisely so the hash chain — and `spec/parity-corpus.json`, which is + * the committed record of it — stays byte-identical. + */ + +// `register()` installs the AsyncLocalStorage context manager, i.e. the shape a +// real service runs in. Without it `context.active()` never sees a span. +new NodeTracerProvider().register(); + +const tracer = trace.getTracer("audit-envelope-test"); + +const event = { + id: "01HXXXXXXXXXXXXXXXXXXXXXXX", + organizationId: "org-1", + actorType: "user" as const, + actorId: "user-1", + action: "crm.contact_created", + resource: { type: "crm.contact", id: "c-1" }, + outcome: "success" as const, + metadata: {}, + timestamp: "2026-05-17T12:00:00.000Z", +}; + +interface Fixture { + name: string; + event: Record; + expectedCanonical: string; + expectedHash: string; +} +const corpus = JSON.parse( + readFileSync(new URL("../spec/parity-corpus.json", import.meta.url), "utf8"), +) as { fixtures: Fixture[] }; + +let bodies: string[] = []; + +/** Emit through the real client and return the parsed wire envelope. */ +async function emitAndCapture(trace?: { + traceId?: string; + spanId?: string; +}): Promise { + const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + const client = new AuditClient({ + endpoint: "https://audit.example/events", + token: "t", + fetchImpl, + }); + await client.emit(event, trace); + const body = fetchImpl.mock.calls[0]![1].body as string; + bodies.push(body); + return JSON.parse(body) as AuditEnvelope; +} + +beforeEach(() => { + bodies = []; +}); + +describe("trace ids on the envelope", () => { + it("carries the active span's traceId and spanId", async () => { + const { envelope, spanContext } = await tracer.startActiveSpan("caller", async (span) => { + const envelope = await emitAndCapture(); + span.end(); + return { envelope, spanContext: span.spanContext() }; + }); + + expect(envelope.traceId).toBe(spanContext.traceId); + expect(envelope.spanId).toBe(spanContext.spanId); + expect(envelope.traceId).toMatch(/^[0-9a-f]{32}$/); + expect(envelope.spanId).toMatch(/^[0-9a-f]{16}$/); + }); + + it("omits both ids entirely when there is no active span", async () => { + const envelope = await emitAndCapture(); + + expect("traceId" in envelope).toBe(false); + expect("spanId" in envelope).toBe(false); + // Never an all-zero id and never an empty string — an unregistered SDK hands + // you all-zeros, and storing that correlates every uncorrelated event to + // itself. The sibling logger shipped exactly that bug. + expect(bodies[0]).not.toContain("00000000"); + expect(bodies[0]).not.toContain('""'); + }); + + it("omits both ids for an INVALID (all-zero) span context", async () => { + const envelope = await context.with( + trace.setSpanContext(context.active(), INVALID_SPAN_CONTEXT), + () => emitAndCapture(), + ); + + expect("traceId" in envelope).toBe(false); + expect(bodies[0]).not.toContain("00000000"); + }); + + it("lets a caller-supplied trace context win over the ambient span", async () => { + const supplied = { traceId: "11111111111111111111111111111111", spanId: "2222222222222222" }; + + const envelope = await tracer.startActiveSpan("caller", async (span) => { + const envelope = await emitAndCapture(supplied); + span.end(); + return envelope; + }); + + expect(envelope.traceId).toBe(supplied.traceId); + expect(envelope.spanId).toBe(supplied.spanId); + }); + + it("treats a caller-supplied empty string as absent", async () => { + const envelope = await emitAndCapture({ traceId: "", spanId: "" }); + + expect("traceId" in envelope).toBe(false); + expect("spanId" in envelope).toBe(false); + }); + + it("reads no context outside a span and a valid one inside", async () => { + expect(await currentTraceContext()).toEqual({}); + await tracer.startActiveSpan("caller", async (span) => { + expect(await currentTraceContext()).toEqual({ + traceId: span.spanContext().traceId, + spanId: span.spanContext().spanId, + }); + span.end(); + }); + }); +}); + +describe("the hash chain is untouched by trace context", () => { + it("reproduces every parity-corpus hash byte-for-byte INSIDE an active span", async () => { + await tracer.startActiveSpan("caller", async (span) => { + // A trace context that leaked into the hashed object would break these the + // same way an added event field would. + for (const fixture of corpus.fixtures) { + expect(computeEventHash(fixture.event as never)).toBe(fixture.expectedHash); + } + span.end(); + }); + }); + + it("seals an event to the same hash with and without a span", async () => { + const withoutSpan = await emitAndCapture(); + const withSpan = await tracer.startActiveSpan("caller", async (span) => { + const envelope = await emitAndCapture(); + span.end(); + return envelope; + }); + + expect(withSpan.event.hashCurrent).toBe(withoutSpan.event.hashCurrent); + expect(withSpan.event.hashCurrent).toBe(computeEventHash(event)); + // And the hashed bytes are the event's own — recomputing from what went over + // the wire (envelope.event minus hashCurrent) reproduces the stamped hash, + // which it could not if traceId/spanId had landed inside the event. + const { hashCurrent, ...rest } = withSpan.event; + expect(computeEventHash(rest as never)).toBe(hashCurrent); + }); + + it("puts the ids beside the event, never inside it", async () => { + const envelope = await tracer.startActiveSpan("caller", async (span) => { + const envelope = await emitAndCapture(); + span.end(); + return envelope; + }); + + expect(envelope.traceId).toBeDefined(); + expect(envelope.event).not.toHaveProperty("traceId"); + expect(envelope.event).not.toHaveProperty("spanId"); + }); + + it("builds an envelope whose `event` bytes are the pre-envelope canonical bytes", async () => { + const sealed = { ...event, hashCurrent: computeEventHash(event) }; + const envelope = await tracer.startActiveSpan("caller", async (span) => { + const built = await buildEnvelope(sealed); + span.end(); + return built; + }); + + expect(envelope.event).toBe(sealed); + }); +}); diff --git a/src/envelope.ts b/src/envelope.ts new file mode 100644 index 0000000..29ee15f --- /dev/null +++ b/src/envelope.ts @@ -0,0 +1,89 @@ +import { canonicalJson } from "./canonical"; +import type { SealedAuditEvent } from "./client"; + +/** + * Trace correlation ids carried alongside an event. + * + * Both fields are optional and are OMITTED — never emitted as `""` or as the + * all-zero `00000000000000000000000000000000` id — when there is nothing real to + * report. An all-zero id is what an unregistered SDK hands you, and writing it + * into the store is worse than writing nothing: it looks like a correlation id + * and joins every other unreported event to itself. + */ +export interface TraceContext { + traceId?: string; + spanId?: string; +} + +/** + * The wire payload: the sealed event PLUS transport-only correlation ids. + * + * `traceId` / `spanId` live HERE, one level above the event, and never inside + * it. The hash chain covers canonical-JSON(event minus `hashCurrent`), so any + * field added to the event changes every hash and invalidates every stored + * chain and the parity corpus. The envelope is outside that boundary: the bytes + * of `envelope.event` are the exact bytes that were hashed, whether or not a + * trace was active. + */ +export interface AuditEnvelope { + event: SealedAuditEvent; + traceId?: string; + spanId?: string; +} + +/** + * Cached, best-effort handle on `@opentelemetry/api`. + * + * The package is an OPTIONAL peer dependency: an OSS audit SDK must not force + * OpenTelemetry on anyone. When it is absent the import rejects, this resolves + * `null`, and correlation becomes a no-op — no crash, no behaviour change. + * Mirrors `@smooai/fetch`'s `injectTraceContext`. + */ +let otelApi: Promise | undefined; + +/** + * Read the active W3C trace context, or an empty context when there is none. + * + * Two guards, each for a reason: + * + * 1. **Optional dependency.** No `@opentelemetry/api` installed → `{}`. + * 2. **Valid span contexts only.** No registered SDK / no active span yields + * either no span context at all or `INVALID_SPAN_CONTEXT` (all-zero ids). + * Reporting the latter poisons the audit trail with a correlation id that + * correlates nothing — the sibling logger shipped exactly that bug. + */ +export async function currentTraceContext(): Promise { + otelApi ??= import("@opentelemetry/api").catch(() => null); + const otel = await otelApi; + if (!otel) return {}; + + const spanContext = otel.trace.getSpanContext(otel.context.active()); + if (!spanContext || !otel.isSpanContextValid(spanContext)) return {}; + return { traceId: spanContext.traceId, spanId: spanContext.spanId }; +} + +/** + * Build the wire envelope for an already-sealed event. `override` wins over the + * ambient span per field — a caller that knows the trace (a queue consumer + * replaying a producer's context, say) is more authoritative than whatever span + * happens to be active at emit time. Empty strings count as "not supplied" on + * both sides, so they can never reach the wire. + */ +export async function buildEnvelope( + event: SealedAuditEvent, + override?: TraceContext, +): Promise { + const ambient = await currentTraceContext(); + return { + event, + // `||` (not `??`) on purpose: it collapses "" to the next candidate and + // finally to undefined, which `canonicalJson` drops entirely. + traceId: override?.traceId || ambient.traceId || undefined, + spanId: override?.spanId || ambient.spanId || undefined, + }; +} + +/** Canonical JSON of the envelope — the exact bytes POSTed to the ingest endpoint. */ +export function envelopeJson(envelope: AuditEnvelope): string { + return canonicalJson(envelope); +} diff --git a/src/index.ts b/src/index.ts index 3829dd4..19d6c03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,10 @@ export { export { canonicalJson } from "./canonical"; export { computeEventHash, buildHashChain, verifyChain } from "./hash"; export { AuditClient, type AuditClientOptions, type SealedAuditEvent } from "./client"; +export { + buildEnvelope, + currentTraceContext, + envelopeJson, + type AuditEnvelope, + type TraceContext, +} from "./envelope"; diff --git a/tsdown.config.ts b/tsdown.config.ts index b012c79..e7da865 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts", "src/schema.ts", "src/canonical.ts", "src/hash.ts", "src/client.ts"], + entry: ["src/index.ts", "src/schema.ts", "src/canonical.ts", "src/hash.ts", "src/client.ts", "src/envelope.ts"], clean: true, dts: true, format: ["cjs", "esm"],