From cdad89a38c9dbe16ae70ae2d260eab0471ec64f7 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Mon, 22 Jan 2024 10:48:37 -0500 Subject: [PATCH] feat: Redact anonymous attributes within feature events (#193) --- .../Impl/Events/EventSerializer.php | 21 ++-- test-service/TestService.php | 4 +- tests/Impl/Events/EventSerializerTest.php | 107 ++++++++++++++++-- 3 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/LaunchDarkly/Impl/Events/EventSerializer.php b/src/LaunchDarkly/Impl/Events/EventSerializer.php index 9730e9bf..706291ee 100644 --- a/src/LaunchDarkly/Impl/Events/EventSerializer.php +++ b/src/LaunchDarkly/Impl/Events/EventSerializer.php @@ -48,10 +48,12 @@ public function serializeEvents(array $events): string private function filterEvent(array $e): array { + $isFeatureEvent = ($e['kind'] ?? '') == 'feature'; + $ret = []; foreach ($e as $key => $value) { if ($key == 'context') { - $ret[$key] = $this->serializeContext($value); + $ret[$key] = $this->serializeContext($value, $isFeatureEvent); } else { $ret[$key] = $value; } @@ -59,23 +61,23 @@ private function filterEvent(array $e): array return $ret; } - private function serializeContext(LDContext $context): array + private function serializeContext(LDContext $context, bool $redactAnonymousAttributes): array { if ($context->isMultiple()) { $ret = ['kind' => 'multi']; for ($i = 0; $i < $context->getIndividualContextCount(); $i++) { $c = $context->getIndividualContext($i); if ($c !== null) { - $ret[$c->getKind()] = $this->serializeContextSingleKind($c, false); + $ret[$c->getKind()] = $this->serializeContextSingleKind($c, false, $redactAnonymousAttributes); } } return $ret; } else { - return $this->serializeContextSingleKind($context, true); + return $this->serializeContextSingleKind($context, true, $redactAnonymousAttributes); } } - private function serializeContextSingleKind(LDContext $c, bool $includeKind): array + private function serializeContextSingleKind(LDContext $c, bool $includeKind, bool $redactAnonymousAttributes): array { $ret = ['key' => $c->getKey()]; if ($includeKind) { @@ -86,11 +88,12 @@ private function serializeContextSingleKind(LDContext $c, bool $includeKind): ar } $redacted = []; $allPrivate = array_merge($this->_privateAttributes, $c->getPrivateAttributes() ?? []); - if ($c->getName() !== null && !$this->checkWholeAttributePrivate('name', $allPrivate, $redacted)) { + $redactAllAttributes = $this->_allAttributesPrivate || ($redactAnonymousAttributes && $c->isAnonymous()); + if ($c->getName() !== null && !$this->checkWholeAttributePrivate('name', $allPrivate, $redacted, $redactAllAttributes)) { $ret['name'] = $c->getName(); } foreach ($c->getCustomAttributeNames() as $attr) { - if (!$this->checkWholeAttributePrivate($attr, $allPrivate, $redacted)) { + if (!$this->checkWholeAttributePrivate($attr, $allPrivate, $redacted, $redactAllAttributes)) { $value = $c->get($attr); $ret[$attr] = self::redactJsonValue(null, $attr, $value, $allPrivate, $redacted); } @@ -101,9 +104,9 @@ private function serializeContextSingleKind(LDContext $c, bool $includeKind): ar return $ret; } - private function checkWholeAttributePrivate(string $attr, array $allPrivate, array &$redactedOut): bool + private function checkWholeAttributePrivate(string $attr, array $allPrivate, array &$redactedOut, bool $redactAllAttributes): bool { - if ($this->_allAttributesPrivate) { + if ($redactAllAttributes) { $redactedOut[] = $attr; return true; } diff --git a/test-service/TestService.php b/test-service/TestService.php index c09a8151..73f0f98a 100644 --- a/test-service/TestService.php +++ b/test-service/TestService.php @@ -75,7 +75,9 @@ public function getStatus(): array 'context-type', 'secure-mode-hash', 'migrations', - 'event-sampling' + 'event-sampling', + 'inline-context', + 'anonymous-redaction' ], 'clientVersion' => \LaunchDarkly\LDClient::VERSION ]; diff --git a/tests/Impl/Events/EventSerializerTest.php b/tests/Impl/Events/EventSerializerTest.php index ec75fcc2..c7e9cee9 100644 --- a/tests/Impl/Events/EventSerializerTest.php +++ b/tests/Impl/Events/EventSerializerTest.php @@ -16,7 +16,7 @@ private function getContext(): LDContext ->set('firstName', 'Sue') ->build(); } - + private function getContextSpecifyingOwnPrivateAttr() { return LDContext::builder('abc') @@ -26,7 +26,7 @@ private function getContextSpecifyingOwnPrivateAttr() ->private('dizzle') ->build(); } - + private function getFullContextResult() { return [ @@ -37,7 +37,7 @@ private function getFullContextResult() 'dizzle' => 'ghi' ]; } - + private function getContextResultWithAllAttrsHidden() { return [ @@ -48,7 +48,7 @@ private function getContextResultWithAllAttrsHidden() ] ]; } - + private function getContextResultWithSomeAttrsHidden() { return [ @@ -60,7 +60,7 @@ private function getContextResultWithSomeAttrsHidden() ] ]; } - + private function getContextResultWithOwnSpecifiedAttrHidden() { return [ @@ -73,7 +73,7 @@ private function getContextResultWithOwnSpecifiedAttrHidden() ] ]; } - + private function makeEvent($context) { return [ @@ -83,14 +83,14 @@ private function makeEvent($context) 'context' => $context ]; } - + private function getJsonForContextBySerializingEvent($user) { $es = new EventSerializer([]); $event = $this->makeEvent($user); return json_decode($es->serializeEvents([$event]), true)[0]['context']; } - + public function testAllContextAttrsSerialized() { $es = new EventSerializer([]); @@ -108,7 +108,92 @@ public function testAllContextAttrsPrivate() $expected = $this->makeEvent($this->getContextResultWithAllAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - + + public function testRedactsAllAttributesFromAnonymousContextWithFeatureEvent() + { + $anonymousContext = LDContext::builder('abc') + ->anonymous(true) + ->set('bizzle', 'def') + ->set('dizzle', 'ghi') + ->set('firstName', 'Sue') + ->build(); + + $es = new EventSerializer([]); + $event = $this->makeEvent($anonymousContext); + $event['kind'] = 'feature'; + $json = $es->serializeEvents([$event]); + + // But we redact all attributes when the context is anonymous + $expectedContextOutput = $this->getContextResultWithAllAttrsHidden(); + $expectedContextOutput['anonymous'] = true; + + $expected = $this->makeEvent($expectedContextOutput); + $expected['kind'] = 'feature'; + + $this->assertEquals([$expected], json_decode($json, true)); + } + + public function testDoesNotRedactAttributesFromAnonymousContextWithNonFeatureEvent() + { + $anonymousContext = LDContext::builder('abc') + ->anonymous(true) + ->set('bizzle', 'def') + ->set('dizzle', 'ghi') + ->set('firstName', 'Sue') + ->build(); + + $es = new EventSerializer([]); + $event = $this->makeEvent($anonymousContext); + $json = $es->serializeEvents([$event]); + + // But we redact all attributes when the context is anonymous + $expectedContextOutput = $this->getFullContextResult(); + $expectedContextOutput['anonymous'] = true; + + $expected = $this->makeEvent($expectedContextOutput); + + $this->assertEquals([$expected], json_decode($json, true)); + } + + public function testRedactsAllAttributesOnlyIfContextIsAnonymous() + { + $userContext = LDContext::builder('user-key') + ->kind('user') + ->anonymous(true) + ->name('Example user') + ->build(); + + $orgContext = LDContext::builder('org-key') + ->kind('org') + ->anonymous(false) + ->name('Example org') + ->build(); + + $multiContext = LDContext::createMulti($userContext, $orgContext); + + $es = new EventSerializer([]); + $event = $this->makeEvent($multiContext); + $event['kind'] = 'feature'; + $json = $es->serializeEvents([$event]); + + $expectedContextOutput = [ + 'kind' => 'multi', + 'user' => [ + 'key' => 'user-key', + 'anonymous' => true, + '_meta' => ['redactedAttributes' => ['name']] + ], + 'org' => [ + 'key' => 'org-key', + 'name' => 'Example org', + ], + ]; + $expected = $this->makeEvent($expectedContextOutput); + $expected['kind'] = 'feature'; + + $this->assertEquals([$expected], json_decode($json, true)); + } + public function testSomeContextAttrsPrivate() { $es = new EventSerializer(['private_attribute_names' => ['firstName', 'bizzle']]); @@ -117,7 +202,7 @@ public function testSomeContextAttrsPrivate() $expected = $this->makeEvent($this->getContextResultWithSomeAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - + public function testPerContextPrivateAttr() { $es = new EventSerializer([]); @@ -135,7 +220,7 @@ public function testPerContextPrivateAttrPlusGlobalPrivateAttrs() $expected = $this->makeEvent($this->getContextResultWithAllAttrsHidden()); $this->assertEquals([$expected], json_decode($json, true)); } - + public function testObjectPropertyRedaction() { $es = new EventSerializer(['private_attribute_names' => ['/b/prop1', '/c/prop2/sub1']]);