diff --git a/lib/sdk/server/contract-tests/test-suppressions.txt b/lib/sdk/server/contract-tests/test-suppressions.txt index e69de29b..7b010a6c 100644 --- a/lib/sdk/server/contract-tests/test-suppressions.txt +++ b/lib/sdk/server/contract-tests/test-suppressions.txt @@ -0,0 +1,3 @@ +events/custom events/single-kind anonymous context redacts all attributes +events/custom events/multi-kind with anonymous context redacts attributes appropriately +migrations/redacts anonymous context attributes diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java index 185eba59..4f5cf5b3 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventOutputFormatter.java @@ -27,11 +27,13 @@ */ final class EventOutputFormatter { private final EventContextFormatter contextFormatter; + private final boolean redactAnonymousAllEvents; EventOutputFormatter(EventsConfiguration config) { this.contextFormatter = new EventContextFormatter( config.allAttributesPrivate, config.privateAttributes.toArray(new AttributeRef[config.privateAttributes.size()])); + this.redactAnonymousAllEvents = config.redactAnonymousAllEvents; } int writeOutputEvents(Event[] events, List summaries, Writer writer) throws IOException { @@ -92,7 +94,11 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException jw.beginObject(); writeKindAndCreationDate(jw, "custom", event.getCreationDate()); jw.name("key").value(ce.getKey()); - writeContext(ce.getContext(), jw, false); + // Anonymous-context attribute redaction in custom events is server-side SDK behavior + // (redactAnonymousAllEvents): server-side SDKs inline the full context and redact, while + // client-side SDKs use the current context and do not redact (only feature events redact + // anonymous on the client). + writeContext(ce.getContext(), jw, redactAnonymousAllEvents); writeLDValue("data", ce.getData(), jw); if (ce.getMetricValue() != null) { jw.name("metricValue"); @@ -107,7 +113,9 @@ private boolean writeOutputEvent(Event event, JsonWriter jw) throws IOException } else if (event instanceof Event.MigrationOp) { jw.beginObject(); writeKindAndCreationDate(jw, "migration_op", event.getCreationDate()); - writeContext(event.getContext(), jw, false); + // migration_op events are only produced by server-side SDKs, which redact anonymous context + // attributes (redactAnonymousAllEvents), consistent with their custom-event handling. + writeContext(event.getContext(), jw, redactAnonymousAllEvents); Event.MigrationOp me = (Event.MigrationOp)event; jw.name("operation").value(me.getOperation()); diff --git a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java index c4474017..6dbd750c 100644 --- a/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java +++ b/lib/shared/internal/src/main/java/com/launchdarkly/sdk/internal/events/EventsConfiguration.java @@ -31,7 +31,8 @@ public final class EventsConfiguration { final boolean initiallyOffline; final List privateAttributes; final boolean perContextSummarization; - + final boolean redactAnonymousAllEvents; + /** * Creates an instance. * @@ -103,6 +104,49 @@ public EventsConfiguration( Collection privateAttributes, boolean perContextSummarization ) { + this(allAttributesPrivate, capacity, contextDeduplicator, diagnosticRecordingIntervalMillis, + diagnosticStore, eventSender, eventSendingThreadPoolSize, eventsUri, flushIntervalMillis, + initiallyInBackground, initiallyOffline, privateAttributes, perContextSummarization, false); + } + + /** + * Creates an instance. + * + * @param allAttributesPrivate true if all attributes are private + * @param capacity event buffer capacity (if zero or negative, a value of 1 is used to prevent errors) + * @param contextDeduplicator optional EventContextDeduplicator; null for client-side SDK + * @param diagnosticRecordingIntervalMillis diagnostic recording interval + * @param diagnosticStore optional DiagnosticStore; null if diagnostics are disabled + * @param eventSender event delivery component; must not be null + * @param eventSendingThreadPoolSize number of worker threads for event delivery; zero to use the default + * @param eventsUri events base URI + * @param flushIntervalMillis event flush interval + * @param initiallyInBackground true if we should start out in background mode (see + * {@link DefaultEventProcessor#setInBackground(boolean)}) + * @param initiallyOffline true if we should start out in offline mode (see + * {@link DefaultEventProcessor#setOffline(boolean)}) + * @param privateAttributes list of private attribute references; may be null + * @param perContextSummarization true to generate separate summary events per context + * @param redactAnonymousAllEvents true to redact anonymous context attributes across all inlined + * events (custom and migration_op events), in addition to feature events which always redact them. + * Server-side SDKs set this to true; client-side SDKs leave it false (redact only feature events). + */ + public EventsConfiguration( + boolean allAttributesPrivate, + int capacity, + EventContextDeduplicator contextDeduplicator, + long diagnosticRecordingIntervalMillis, + DiagnosticStore diagnosticStore, + EventSender eventSender, + int eventSendingThreadPoolSize, + URI eventsUri, + long flushIntervalMillis, + boolean initiallyInBackground, + boolean initiallyOffline, + Collection privateAttributes, + boolean perContextSummarization, + boolean redactAnonymousAllEvents + ) { super(); this.allAttributesPrivate = allAttributesPrivate; this.capacity = capacity >= 0 ? capacity : 1; @@ -118,5 +162,6 @@ public EventsConfiguration( this.initiallyOffline = initiallyOffline; this.privateAttributes = privateAttributes == null ? Collections.emptyList() : new ArrayList<>(privateAttributes); this.perContextSummarization = perContextSummarization; + this.redactAnonymousAllEvents = redactAnonymousAllEvents; } } \ No newline at end of file diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java index 18fadac6..776185cc 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/BaseEventTest.java @@ -334,6 +334,7 @@ public static class EventsConfigurationBuilder { private boolean initiallyOffline = false; private Set privateAttributes = new HashSet<>(); private boolean perContextSummarization = false; + private boolean redactAnonymousAllEvents = false; public EventsConfiguration build() { return new EventsConfiguration( @@ -349,10 +350,16 @@ public EventsConfiguration build() { initiallyInBackground, initiallyOffline, privateAttributes, - perContextSummarization + perContextSummarization, + redactAnonymousAllEvents ); } + public EventsConfigurationBuilder redactAnonymousAllEvents(boolean redactAnonymousAllEvents) { + this.redactAnonymousAllEvents = redactAnonymousAllEvents; + return this; + } + public EventsConfigurationBuilder allAttributesPrivate(boolean allAttributesPrivate) { this.allAttributesPrivate = allAttributesPrivate; return this; diff --git a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java index d10c23ac..27ed2d15 100644 --- a/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java +++ b/lib/shared/internal/src/test/java/com/launchdarkly/sdk/internal/events/EventOutputTest.java @@ -291,6 +291,114 @@ public void customEventIsSerialized() throws IOException { assertJsonEquals(ceJson4, getSingleOutputEvent(f, ceWithDataAndMetric)); } + @Test + public void customEventRedactsAnonymousContextAttributes() throws Exception { + // Server-side SDK behavior: redactAnonymousAllEvents is enabled. + EventOutputFormatter f = new EventOutputFormatter( + new EventsConfigurationBuilder().redactAnonymousAllEvents(true).build()); + + // Single-kind anonymous context: every optional attribute is redacted. + LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build(); + Event.Custom singleKind = customEvent(userContext, "customkey").build(); + LDValue singleContextJson = LDValue.buildObject() + .put("kind", "user") + .put("key", "userkey") + .put("anonymous", true) + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}")) + .build(); + LDValue singleJson = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"context\":" + singleContextJson + + "}"); + assertJsonEquals(singleJson, getSingleOutputEvent(f, singleKind)); + + // Multi-kind context: only the anonymous kind (user) is redacted; org is untouched. + LDContext orgContext = LDContext.builder("orgkey").anonymous(false).kind("org").name("me").set("age", 42).build(); + LDContext multiContext = LDContext.createMulti(userContext, orgContext); + Event.Custom multiKind = customEvent(multiContext, "customkey").build(); + LDValue userJson = LDValue.buildObject() + .put("key", "userkey") + .put("anonymous", true) + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}")) + .build(); + LDValue orgJson = LDValue.buildObject() + .put("key", "orgkey") + .put("name", "me") + .put("age", 42) + .build(); + LDValue multiContextJson = LDValue.buildObject() + .put("kind", "multi") + .put("user", userJson) + .put("org", orgJson) + .build(); + LDValue multiJson = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"context\":" + multiContextJson + + "}"); + assertJsonEquals(multiJson, getSingleOutputEvent(f, multiKind)); + } + + @Test + public void customEventDoesNotRedactAnonymousContextWhenNotEnabled() throws Exception { + // Client-side SDK behavior: with redactAnonymousAllEvents left false (the + // default), custom events carry the full anonymous context, including its attributes. + EventOutputFormatter f = new EventOutputFormatter(defaultEventsConfig()); + + LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build(); + Event.Custom ce = customEvent(userContext, "customkey").build(); + LDValue contextJson = LDValue.buildObject() + .put("kind", "user") + .put("key", "userkey") + .put("anonymous", true) + .put("name", "me") + .put("age", 42) + .build(); + LDValue ceJson = parseValue("{" + + "\"kind\":\"custom\"," + + "\"creationDate\":100000," + + "\"key\":\"customkey\"," + + "\"context\":" + contextJson + + "}"); + assertJsonEquals(ceJson, getSingleOutputEvent(f, ce)); + } + + @Test + public void migrationOpEventRedactsAnonymousContextAttributes() throws Exception { + // Server-side SDK behavior: redactAnonymousAllEvents is enabled (migration_op events are + // only produced server-side). + EventOutputFormatter f = new EventOutputFormatter( + new EventsConfigurationBuilder().redactAnonymousAllEvents(true).build()); + + LDContext userContext = LDContext.builder("userkey").anonymous(true).name("me").set("age", 42).build(); + Event.MigrationOp migrationEvent = new Event.MigrationOp( + 100000, + userContext, + "migration-key", + 1, + 2, + LDValue.of("live"), + LDValue.of("off"), + EvaluationReason.fallthrough(false), + 1, + "read", + new Event.MigrationOp.InvokedMeasurement(true, false), + null, + null, + null + ); + LDValue expectedContext = LDValue.buildObject() + .put("kind", "user") + .put("key", "userkey") + .put("anonymous", true) + .put("_meta", LDValue.parse("{\"redactedAttributes\":[\"name\", \"age\"]}")) + .build(); + assertJsonEquals(expectedContext, getSingleOutputEvent(f, migrationEvent).get("context")); + } + @Test public void summaryEventIsSerialized() throws Exception { LDValue value1a = LDValue.of("value1a"), value2a = LDValue.of("value2a"), value2b = LDValue.of("value2b"),