From 4022ee2a785dafffab220baf94c5b9610dee0022 Mon Sep 17 00:00:00 2001 From: "Matthew M. Keeler" Date: Tue, 16 Jan 2024 10:05:18 -0500 Subject: [PATCH] feat: Redact anonymous attributes within feature events (#246) --- contract-tests/service.py | 3 +- .../impl/events/event_context_formatter.py | 32 +++++++++++++---- ldclient/impl/events/event_processor.py | 13 ++++--- .../events/test_event_context_formatter.py | 35 +++++++++++++++++++ 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/contract-tests/service.py b/contract-tests/service.py index fea8b261..201d5b28 100644 --- a/contract-tests/service.py +++ b/contract-tests/service.py @@ -72,7 +72,8 @@ def status(): 'migrations', 'event-sampling', 'polling-gzip', - '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 a2929537..23c3e5ad 100644 --- a/ldclient/impl/events/event_context_formatter.py +++ b/ldclient/impl/events/event_context_formatter.py @@ -18,20 +18,38 @@ def __init__(self, all_attributes_private: bool, private_attributes: List[str]): self._private_attributes.append(ar) def format_context(self, context: Context) -> Dict: + """ + Formats a context for use in an analytic event, performing any + necessary attribute redaction. + """ + return self._format_context(context, False) + + def format_context_redact_anonymous(self, context: Context) -> Dict: + """ + Formats a context for use in an analytic event, performing any + necessary attribute redaction. + + If a context is anonoymous, all attributes will be redacted except for + key, kind, and anonoymous. + """ + return self._format_context(context, True) + + 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) + return self._format_context_single(context, True, redact_anonymous) - def _format_context_single(self, context: Context, include_kind: bool) -> Dict: + 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 @@ -44,11 +62,11 @@ def _format_context_single(self, context: Context, include_kind: bool) -> Dict: 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) @@ -57,8 +75,8 @@ def _format_context_single(self, context: Context, include_kind: bool) -> Dict: 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: diff --git a/ldclient/impl/events/event_processor.py b/ldclient/impl/events/event_processor.py index 15562382..b86b74bf 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,7 +193,10 @@ def make_summary_event(self, summary: EventSummary): 'features': flags_out } - def _process_context(self, context: Context): + def _process_context(self, context: Context, redact_anonymous: bool): + if redact_anonymous: + return self._context_formatter.format_context_redact_anonymous(context) + return self._context_formatter.format_context(context) def _context_keys(self, context: Context): diff --git a/testing/impl/events/test_event_context_formatter.py b/testing/impl/events/test_event_context_formatter.py index 06662ab3..60421811 100644 --- a/testing/impl/events/test_event_context_formatter.py +++ b/testing/impl/events/test_event_context_formatter.py @@ -18,6 +18,41 @@ 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_redact_anonymous(c) == { + '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_redact_anonymous(multi) == { + '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(