diff --git a/contract-tests/service.py b/contract-tests/service.py index 5b53855..c974209 100644 --- a/contract-tests/service.py +++ b/contract-tests/service.py @@ -70,7 +70,8 @@ def status(): 'tags', 'migrations', 'event-sampling', - 'inline-context' + 'inline-context', + 'anonymous-redaction', ] } return (json.dumps(body), 200, {'Content-type': 'application/json'}) diff --git a/ldclient/impl/events/event_context_formatter.py b/ldclient/impl/events/event_context_formatter.py index 7af7b50..9b7f839 100644 --- a/ldclient/impl/events/event_context_formatter.py +++ b/ldclient/impl/events/event_context_formatter.py @@ -17,24 +17,25 @@ def __init__(self, all_attributes_private: bool, private_attributes: List[str]): if ar.valid: self._private_attributes.append(ar) - def format_context(self, context: Context) -> dict: + def format_context(self, context: Context, redact_anonymous: bool) -> dict: if context.multiple: out = {'kind': 'multi'} # type: dict[str, Any] for i in range(context.individual_context_count): c = context.get_individual_context(i) if c is not None: - out[c.kind] = self._format_context_single(c, False) + out[c.kind] = self._format_context_single(c, False, redact_anonymous) return out else: - return self._format_context_single(context, True) - - def _format_context_single(self, context: Context, include_kind: bool) -> dict: + return self._format_context_single(context, True, redact_anonymous) + + def _format_context_single(self, context: Context, include_kind: bool, redact_anonymous: bool) -> dict: out = {'key': context.key} # type: dict[str, Any] if include_kind: out['kind'] = context.kind + if context.anonymous: out['anonymous'] = True - + redacted = [] # type: List[str] all_private = self._private_attributes for p in context.private_attributes: @@ -43,22 +44,22 @@ def _format_context_single(self, context: Context, include_kind: bool) -> dict: ar = AttributeRef.from_path(p) if ar.valid: all_private.append(ar) - - if context.name is not None and not self._check_whole_attr_private('name', all_private, redacted): + + if context.name is not None and not self._check_whole_attr_private('name', all_private, redacted, context.anonymous and redact_anonymous): out['name'] = context.name for attr in context.custom_attributes: - if not self._check_whole_attr_private(attr, all_private, redacted): + if not self._check_whole_attr_private(attr, all_private, redacted, context.anonymous and redact_anonymous): value = context.get(attr) out[attr] = self._redact_json_value(None, attr, value, all_private, redacted) - + if len(redacted) != 0: out['_meta'] = {'redactedAttributes': redacted} - + return out - def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted: List[str]) -> bool: - if self._all_attributes_private: + def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted: List[str], redact_all: bool) -> bool: + if self._all_attributes_private or redact_all: redacted.append(attr) return True for p in all_private: @@ -66,7 +67,7 @@ def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted.append(attr) return True return False - + def _redact_json_value(self, parent_path: Optional[List[str]], name: str, value: Any, all_private: List[AttributeRef], redacted: List[str]) -> Any: if not isinstance(value, dict) or len(value) == 0: diff --git a/ldclient/impl/events/event_processor.py b/ldclient/impl/events/event_processor.py index 2ce6bd3..9ff1dbf 100644 --- a/ldclient/impl/events/event_processor.py +++ b/ldclient/impl/events/event_processor.py @@ -65,23 +65,23 @@ def make_output_events(self, events: List[Any], summary: EventSummary): def make_output_event(self, e: Any): if isinstance(e, EventInputEvaluation): out = self._base_eval_props(e, 'feature') - out['context'] = self._process_context(e.context) + out['context'] = self._process_context(e.context, True) return out elif isinstance(e, DebugEvent): out = self._base_eval_props(e.original_input, 'debug') - out['context'] = self._process_context(e.original_input.context) + out['context'] = self._process_context(e.original_input.context, False) return out elif isinstance(e, EventInputIdentify): return { 'kind': 'identify', 'creationDate': e.timestamp, - 'context': self._process_context(e.context) + 'context': self._process_context(e.context, False) } elif isinstance(e, IndexEvent): return { 'kind': 'index', 'creationDate': e.timestamp, - 'context': self._process_context(e.context) + 'context': self._process_context(e.context, False) } elif isinstance(e, EventInputCustom): out = { @@ -193,8 +193,8 @@ def make_summary_event(self, summary: EventSummary): 'features': flags_out } - def _process_context(self, context: Context): - return self._context_formatter.format_context(context) + def _process_context(self, context: Context, redact_anonymous: bool): + return self._context_formatter.format_context(context, redact_anonymous) def _context_keys(self, context: Context): out = {} diff --git a/testing/impl/events/test_event_context_formatter.py b/testing/impl/events/test_event_context_formatter.py index 06662ab..10c31b1 100644 --- a/testing/impl/events/test_event_context_formatter.py +++ b/testing/impl/events/test_event_context_formatter.py @@ -4,12 +4,12 @@ def test_simple_context(): f = EventContextFormatter(False, []) c = Context.create('a') - assert f.format_context(c) == {'kind': 'user', 'key': 'a'} + assert f.format_context(c, False) == {'kind': 'user', 'key': 'a'} def test_context_with_more_attributes(): f = EventContextFormatter(False, []) c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).build() - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'user', 'key': 'a', 'name': 'b', @@ -18,13 +18,48 @@ def test_context_with_more_attributes(): 'd': 2 } +def test_context_can_redact_anonymous_attributes(): + f = EventContextFormatter(False, []) + c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).build() + assert f.format_context(c, True) == { + 'kind': 'user', + 'key': 'a', + 'anonymous': True, + '_meta': { + 'redactedAttributes': ['name', 'c', 'd'] + } + } + +def test_multi_kind_context_can_redact_anonymous_attributes(): + f = EventContextFormatter(False, []) + user = Context.builder('user-key').name('b').anonymous(True).set('c', True).set('d', 2).build() + org = Context.builder('org-key').kind('org').name('b').set('c', True).set('d', 2).build() + multi = Context.create_multi(user, org) + + assert f.format_context(multi, True) == { + 'kind': 'multi', + 'user': { + 'key': 'user-key', + 'anonymous': True, + '_meta': { + 'redactedAttributes': ['name', 'c', 'd'] + } + }, + 'org': { + 'key': 'org-key', + 'name': 'b', + 'c': True, + 'd': 2 + } + } + def test_multi_context(): f = EventContextFormatter(False, []) c = Context.create_multi( Context.create('a'), Context.builder('b').kind('c').name('d').build() ) - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'multi', 'user': { 'key': 'a' @@ -38,7 +73,7 @@ def test_multi_context(): def test_all_private(): f = EventContextFormatter(True, []) c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).build() - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'user', 'key': 'a', 'anonymous': True, @@ -48,7 +83,7 @@ def test_all_private(): def test_some_private_global(): f = EventContextFormatter(False, ['name', 'd']) c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).build() - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'user', 'key': 'a', 'anonymous': True, @@ -59,7 +94,7 @@ def test_some_private_global(): def test_some_private_per_context(): f = EventContextFormatter(False, ['name']) c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).private('d').build() - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'user', 'key': 'a', 'anonymous': True, @@ -73,7 +108,7 @@ def test_private_property_in_object(): .set('b', {'prop1': True, 'prop2': 3}) \ .set('c', {'prop1': {'sub1': True}, 'prop2': {'sub1': 4, 'sub2': 5}}) \ .build() - assert f.format_context(c) == { + assert f.format_context(c, False) == { 'kind': 'user', 'key': 'a', 'b': {'prop2': 3}, diff --git a/testing/impl/events/test_event_processor.py b/testing/impl/events/test_event_processor.py index d0b7c0a..ce71e5b 100644 --- a/testing/impl/events/test_event_processor.py +++ b/testing/impl/events/test_event_processor.py @@ -234,7 +234,7 @@ def test_context_is_filtered_in_identify_event(): output = flush_and_get_events(ep) assert len(output) == 1 - check_identify_event(output[0], e, formatter.format_context(context)) + check_identify_event(output[0], e, formatter.format_context(context, False)) def test_individual_feature_event_is_queued_with_index_event(): with DefaultTestProcessor() as ep: @@ -277,8 +277,8 @@ def test_context_is_filtered_in_index_event(): output = flush_and_get_events(ep) assert len(output) == 3 - check_index_event(output[0], e, formatter.format_context(context)) - check_feature_event(output[1], e, formatter.format_context(context)) + check_index_event(output[0], e, formatter.format_context(context, False)) + check_feature_event(output[1], e, formatter.format_context(context, False)) check_summary_event(output[2]) def test_two_events_for_same_context_only_produce_one_index_event():