Skip to content

Commit 4022ee2

Browse files
committed
feat: Redact anonymous attributes within feature events (#246)
1 parent 8b5429b commit 4022ee2

File tree

4 files changed

+70
-13
lines changed

4 files changed

+70
-13
lines changed

contract-tests/service.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ def status():
7272
'migrations',
7373
'event-sampling',
7474
'polling-gzip',
75-
'inline-context'
75+
'inline-context',
76+
'anonymous-redaction',
7677
]
7778
}
7879
return (json.dumps(body), 200, {'Content-type': 'application/json'})

ldclient/impl/events/event_context_formatter.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,38 @@ def __init__(self, all_attributes_private: bool, private_attributes: List[str]):
1818
self._private_attributes.append(ar)
1919

2020
def format_context(self, context: Context) -> Dict:
21+
"""
22+
Formats a context for use in an analytic event, performing any
23+
necessary attribute redaction.
24+
"""
25+
return self._format_context(context, False)
26+
27+
def format_context_redact_anonymous(self, context: Context) -> Dict:
28+
"""
29+
Formats a context for use in an analytic event, performing any
30+
necessary attribute redaction.
31+
32+
If a context is anonoymous, all attributes will be redacted except for
33+
key, kind, and anonoymous.
34+
"""
35+
return self._format_context(context, True)
36+
37+
def _format_context(self, context: Context, redact_anonymous: bool) -> Dict:
2138
if context.multiple:
2239
out = {'kind': 'multi'} # type: Dict[str, Any]
2340
for i in range(context.individual_context_count):
2441
c = context.get_individual_context(i)
2542
if c is not None:
26-
out[c.kind] = self._format_context_single(c, False)
43+
out[c.kind] = self._format_context_single(c, False, redact_anonymous)
2744
return out
2845
else:
29-
return self._format_context_single(context, True)
46+
return self._format_context_single(context, True, redact_anonymous)
3047

31-
def _format_context_single(self, context: Context, include_kind: bool) -> Dict:
48+
def _format_context_single(self, context: Context, include_kind: bool, redact_anonymous: bool) -> Dict:
3249
out = {'key': context.key} # type: Dict[str, Any]
3350
if include_kind:
3451
out['kind'] = context.kind
52+
3553
if context.anonymous:
3654
out['anonymous'] = True
3755

@@ -44,11 +62,11 @@ def _format_context_single(self, context: Context, include_kind: bool) -> Dict:
4462
if ar.valid:
4563
all_private.append(ar)
4664

47-
if context.name is not None and not self._check_whole_attr_private('name', all_private, redacted):
65+
if context.name is not None and not self._check_whole_attr_private('name', all_private, redacted, context.anonymous and redact_anonymous):
4866
out['name'] = context.name
4967

5068
for attr in context.custom_attributes:
51-
if not self._check_whole_attr_private(attr, all_private, redacted):
69+
if not self._check_whole_attr_private(attr, all_private, redacted, context.anonymous and redact_anonymous):
5270
value = context.get(attr)
5371
out[attr] = self._redact_json_value(None, attr, value, all_private, redacted)
5472

@@ -57,8 +75,8 @@ def _format_context_single(self, context: Context, include_kind: bool) -> Dict:
5775

5876
return out
5977

60-
def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted: List[str]) -> bool:
61-
if self._all_attributes_private:
78+
def _check_whole_attr_private(self, attr: str, all_private: List[AttributeRef], redacted: List[str], redact_all: bool) -> bool:
79+
if self._all_attributes_private or redact_all:
6280
redacted.append(attr)
6381
return True
6482
for p in all_private:

ldclient/impl/events/event_processor.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,23 @@ def make_output_events(self, events: List[Any], summary: EventSummary):
6565
def make_output_event(self, e: Any):
6666
if isinstance(e, EventInputEvaluation):
6767
out = self._base_eval_props(e, 'feature')
68-
out['context'] = self._process_context(e.context)
68+
out['context'] = self._process_context(e.context, True)
6969
return out
7070
elif isinstance(e, DebugEvent):
7171
out = self._base_eval_props(e.original_input, 'debug')
72-
out['context'] = self._process_context(e.original_input.context)
72+
out['context'] = self._process_context(e.original_input.context, False)
7373
return out
7474
elif isinstance(e, EventInputIdentify):
7575
return {
7676
'kind': 'identify',
7777
'creationDate': e.timestamp,
78-
'context': self._process_context(e.context)
78+
'context': self._process_context(e.context, False)
7979
}
8080
elif isinstance(e, IndexEvent):
8181
return {
8282
'kind': 'index',
8383
'creationDate': e.timestamp,
84-
'context': self._process_context(e.context)
84+
'context': self._process_context(e.context, False)
8585
}
8686
elif isinstance(e, EventInputCustom):
8787
out = {
@@ -193,7 +193,10 @@ def make_summary_event(self, summary: EventSummary):
193193
'features': flags_out
194194
}
195195

196-
def _process_context(self, context: Context):
196+
def _process_context(self, context: Context, redact_anonymous: bool):
197+
if redact_anonymous:
198+
return self._context_formatter.format_context_redact_anonymous(context)
199+
197200
return self._context_formatter.format_context(context)
198201

199202
def _context_keys(self, context: Context):

testing/impl/events/test_event_context_formatter.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,41 @@ def test_context_with_more_attributes():
1818
'd': 2
1919
}
2020

21+
def test_context_can_redact_anonymous_attributes():
22+
f = EventContextFormatter(False, [])
23+
c = Context.builder('a').name('b').anonymous(True).set('c', True).set('d', 2).build()
24+
assert f.format_context_redact_anonymous(c) == {
25+
'kind': 'user',
26+
'key': 'a',
27+
'anonymous': True,
28+
'_meta': {
29+
'redactedAttributes': ['name', 'c', 'd']
30+
}
31+
}
32+
33+
def test_multi_kind_context_can_redact_anonymous_attributes():
34+
f = EventContextFormatter(False, [])
35+
user = Context.builder('user-key').name('b').anonymous(True).set('c', True).set('d', 2).build()
36+
org = Context.builder('org-key').kind('org').name('b').set('c', True).set('d', 2).build()
37+
multi = Context.create_multi(user, org)
38+
39+
assert f.format_context_redact_anonymous(multi) == {
40+
'kind': 'multi',
41+
'user': {
42+
'key': 'user-key',
43+
'anonymous': True,
44+
'_meta': {
45+
'redactedAttributes': ['name', 'c', 'd']
46+
}
47+
},
48+
'org': {
49+
'key': 'org-key',
50+
'name': 'b',
51+
'c': True,
52+
'd': 2
53+
}
54+
}
55+
2156
def test_multi_context():
2257
f = EventContextFormatter(False, [])
2358
c = Context.create_multi(

0 commit comments

Comments
 (0)