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"],