diff --git a/contract-tests/client-contract-tests/src/main.cpp b/contract-tests/client-contract-tests/src/main.cpp index 5ab6d34b6..f52590d11 100644 --- a/contract-tests/client-contract-tests/src/main.cpp +++ b/contract-tests/client-contract-tests/src/main.cpp @@ -41,6 +41,7 @@ int main(int argc, char* argv[]) { srv.add_capability("service-endpoints"); srv.add_capability("tags"); srv.add_capability("inline-context"); + srv.add_capability("anonymous-redaction"); net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/contract-tests/server-contract-tests/src/main.cpp b/contract-tests/server-contract-tests/src/main.cpp index 1f0c1c0b9..0cd11d688 100644 --- a/contract-tests/server-contract-tests/src/main.cpp +++ b/contract-tests/server-contract-tests/src/main.cpp @@ -41,6 +41,7 @@ int main(int argc, char* argv[]) { srv.add_capability("tags"); srv.add_capability("server-side-polling"); srv.add_capability("inline-context"); + srv.add_capability("anonymous-redaction"); net::signal_set signals{ioc, SIGINT, SIGTERM}; diff --git a/libs/internal/include/launchdarkly/context_filter.hpp b/libs/internal/include/launchdarkly/context_filter.hpp index 5580ff94f..5b29afcd0 100644 --- a/libs/internal/include/launchdarkly/context_filter.hpp +++ b/libs/internal/include/launchdarkly/context_filter.hpp @@ -34,6 +34,17 @@ class ContextFilter { */ JsonValue Filter(Context const& context); + /** + * Filter the given context and produce a JSON value. If a provided context + * is anonymous, all attributes are redacted. + * + * Only call this method for valid contexts. + * + * @param context The context to redact. + * @return JSON suitable for an analytics event. + */ + JsonValue FilterWithAnonymousRedaction(Context const& context); + private: /** * The filtering and JSON conversion algorithm is stack based @@ -48,6 +59,16 @@ class ContextFilter { JsonValue& parent; }; + /** + * Internal delegate function for Filter and FilterWithAnonymousRedaction. + * + * @param context The context to redact. + * @param redactAnonymous Whether to redact all attributes from anonymous + * contexts. + * @return JSON suitable for an analytics event. + */ + JsonValue Filter(Context const& context, bool redactAnonymous); + /** * Put an item into its parent. Either as a pair in a map, * or pushed onto an array. @@ -63,11 +84,13 @@ class ContextFilter { * @param path The path to check. * @param attributes Attributes which may contain additional private * attributes. + * @param redactAll Force all attributes to be redacted. * @return True if the item was redacted. */ bool Redact(std::vector& redactions, std::vector path, - Attributes const& attributes); + Attributes const& attributes, + bool redactAll); /** * Append a container to the parent. @@ -85,9 +108,10 @@ class ContextFilter { JsonValue FilterSingleContext(std::string_view kind, bool include_kind, - Attributes const& attributes); + Attributes const& attributes, + bool redactAnonymous); - JsonValue FilterMultiContext(Context const& context); + JsonValue FilterMultiContext(Context const& context, bool redactAnonymous); bool all_attributes_private_; AttributeReference::SetType const global_private_attributes_; diff --git a/libs/internal/src/context_filter.cpp b/libs/internal/src/context_filter.cpp index 78b75c494..9ad9e50eb 100644 --- a/libs/internal/src/context_filter.cpp +++ b/libs/internal/src/context_filter.cpp @@ -12,14 +12,24 @@ ContextFilter::ContextFilter( global_private_attributes_(std::move(global_private_attributes)) {} ContextFilter::JsonValue ContextFilter::Filter(Context const& context) { + return Filter(context, false); +} + +ContextFilter::JsonValue ContextFilter::FilterWithAnonymousRedaction( + Context const& context) { + return Filter(context, true); +} + +ContextFilter::JsonValue ContextFilter::Filter(Context const& context, + bool redactAnonymous) { // Context should be validated before calling this method. assert(context.Valid()); if (context.Kinds().size() == 1) { std::string const& kind = context.Kinds()[0]; - return FilterSingleContext(kind, INCLUDE_KIND, - context.Attributes(kind)); + return FilterSingleContext(kind, INCLUDE_KIND, context.Attributes(kind), + redactAnonymous); } - return FilterMultiContext(context); + return FilterMultiContext(context, redactAnonymous); } void ContextFilter::Emplace(ContextFilter::StackItem& item, @@ -33,8 +43,9 @@ void ContextFilter::Emplace(ContextFilter::StackItem& item, bool ContextFilter::Redact(std::vector& redactions, std::vector path, - Attributes const& attributes) { - if (all_attributes_private_) { + Attributes const& attributes, + bool redactAll) { + if (redactAll) { redactions.push_back(AttributeReference::PathToStringReference(path)); return true; } @@ -61,7 +72,8 @@ bool ContextFilter::Redact(std::vector& redactions, ContextFilter::JsonValue ContextFilter::FilterSingleContext( std::string_view kind, bool include_kind, - Attributes const& attributes) { + Attributes const& attributes, + bool redactAnonymous) { std::vector stack; JsonValue filtered = JsonObject(); std::vector redactions; @@ -74,9 +86,11 @@ ContextFilter::JsonValue ContextFilter::FilterSingleContext( filtered.as_object().emplace("anonymous", attributes.Anonymous()); } + bool redactAll = + all_attributes_private_ || (redactAnonymous && attributes.Anonymous()); if (!attributes.Name().empty() && - !Redact(redactions, std::vector{"name"}, - attributes)) { + !Redact(redactions, std::vector{"name"}, attributes, + redactAll)) { filtered.as_object().insert_or_assign("name", attributes.Name()); } @@ -90,7 +104,8 @@ ContextFilter::JsonValue ContextFilter::FilterSingleContext( stack.pop_back(); // Check if the attribute needs to be redacted. - if (!item.path.empty() && Redact(redactions, item.path, attributes)) { + if (!item.path.empty() && + Redact(redactions, item.path, attributes, redactAll)) { continue; } @@ -170,14 +185,16 @@ ContextFilter::JsonValue* ContextFilter::AppendContainer( } ContextFilter::JsonValue ContextFilter::FilterMultiContext( - Context const& context) { + Context const& context, + bool redactAnonymous) { JsonValue filtered = JsonObject(); filtered.as_object().emplace("kind", "multi"); for (std::string const& kind : context.Kinds()) { filtered.as_object().emplace( kind, - FilterSingleContext(kind, EXCLUDE_KIND, context.Attributes(kind))); + FilterSingleContext(kind, EXCLUDE_KIND, context.Attributes(kind), + redactAnonymous)); } return filtered; diff --git a/libs/internal/src/events/asio_event_processor.cpp b/libs/internal/src/events/asio_event_processor.cpp index 4050df977..e9cf2b525 100644 --- a/libs/internal/src/events/asio_event_processor.cpp +++ b/libs/internal/src/events/asio_event_processor.cpp @@ -255,7 +255,8 @@ std::vector AsioEventProcessor::Process( if (event.require_full_event) { out.emplace_back(FeatureEvent{ - std::move(base), filter_.filter(event.context)}); + std::move(base), + filter_.FilterWithAnonymousRedaction(event.context)}); } }, [&](IdentifyEventParams&& event) { diff --git a/libs/internal/tests/context_filter_test.cpp b/libs/internal/tests/context_filter_test.cpp index 771b4c0a1..1f28455b9 100644 --- a/libs/internal/tests/context_filter_test.cpp +++ b/libs/internal/tests/context_filter_test.cpp @@ -173,3 +173,56 @@ TEST(ContextFilterTests, AllAttributesPrivateMultiContext) { EXPECT_EQ(expected, filtered); } + +TEST(ContextFilterTests, AnonymousRedactionForSingleContext) { + auto global_private_attributes = AttributeReference::SetType{}; + ContextFilter filter(false, global_private_attributes); + + auto filtered = + filter.FilterWithAnonymousRedaction(ContextBuilder() + .Kind("user", "user-key") + .Name("Bob") + .Anonymous(true) + .Set("isCat", false) + .Build()); + + auto expected = parse( + "{\"key\":\"user-key\"," + "\"kind\":\"user\"," + "\"anonymous\":true," + "\"_meta\":{" + "\"redactedAttributes\":[\"/name\", \"/isCat\"]}}"); + + EXPECT_EQ(expected, filtered); +} + +TEST(ContextFilterTests, AnonymousRedactionForMultiContext) { + auto global_private_attributes = AttributeReference::SetType{}; + ContextFilter filter(false, global_private_attributes); + + auto filtered = filter.FilterWithAnonymousRedaction( + ContextBuilder() + .Kind("user", "user-key") + .Name("Bob") + .Anonymous(true) + .Set("email", "email.email@email") + .Set("array", {false, true, "bacon"}) + .Set("object", Object{{"test", true}, {"second", false}}) + .SetPrivate("isCat", false) + .AddPrivateAttribute("/object/test") + .Kind("org", "org-key") + .Set("isCat", true) + .Set("email", "cat@cat.cat") + .Build()); + + auto expected = parse( + "{\"kind\":\"multi\"" + ",\"org\":" + "{\"key\":\"org-key\",\"isCat\":true,\"email\":\"cat@cat.cat\"}," + "\"user\":" + "{\"key\":\"user-key\",\"anonymous\":true,\"_meta\":{" + "\"redactedAttributes\":[\"/name\",\"/object\",\"/isCat\",\"/" + "email\",\"/array\"]}}}"); + + EXPECT_EQ(expected, filtered); +} diff --git a/libs/internal/tests/event_serialization_test.cpp b/libs/internal/tests/event_serialization_test.cpp index 053238f40..8ed4c76ac 100644 --- a/libs/internal/tests/event_serialization_test.cpp +++ b/libs/internal/tests/event_serialization_test.cpp @@ -28,7 +28,7 @@ TEST(EventSerialization, FeatureEvent) { std::nullopt, }), - filter.filter(context)}; + filter.Filter(context)}; auto event_json = boost::json::value_from(event);