orgdatacore 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- orgdatacore/__init__.py +156 -0
- orgdatacore/_anonymization.py +465 -0
- orgdatacore/_async.py +1516 -0
- orgdatacore/_exceptions.py +82 -0
- orgdatacore/_gcs.py +317 -0
- orgdatacore/_internal/__init__.py +5 -0
- orgdatacore/_internal/testing/__init__.py +16 -0
- orgdatacore/_internal/testing/fake_gcs.py +173 -0
- orgdatacore/_internal/testing/filesource.py +147 -0
- orgdatacore/_internal/testing/helpers.py +192 -0
- orgdatacore/_log.py +98 -0
- orgdatacore/_redaction.py +215 -0
- orgdatacore/_serialization.py +24 -0
- orgdatacore/_service.py +1458 -0
- orgdatacore/_types.py +269 -0
- orgdatacore/_version.py +119 -0
- orgdatacore/py.typed +0 -0
- orgdatacore-2.0.0.dist-info/METADATA +444 -0
- orgdatacore-2.0.0.dist-info/RECORD +21 -0
- orgdatacore-2.0.0.dist-info/WHEEL +4 -0
- orgdatacore-2.0.0.dist-info/licenses/LICENSE +201 -0
orgdatacore/__init__.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
orgdatacore - Python library for organizational data management.
|
|
3
|
+
|
|
4
|
+
This is a Python port of the Go orgdatacore library, providing thread-safe
|
|
5
|
+
access to organizational data including employees, teams, organizations,
|
|
6
|
+
pillars, and team groups.
|
|
7
|
+
|
|
8
|
+
Example with GCS:
|
|
9
|
+
from orgdatacore import Service, GCSConfig, GCSDataSource
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
config = GCSConfig(
|
|
13
|
+
bucket="your-bucket",
|
|
14
|
+
object_path="path/to/data.json",
|
|
15
|
+
project_id="your-project",
|
|
16
|
+
check_interval=timedelta(minutes=5),
|
|
17
|
+
)
|
|
18
|
+
source = GCSDataSource(config)
|
|
19
|
+
|
|
20
|
+
service = Service()
|
|
21
|
+
service.load_from_data_source(source)
|
|
22
|
+
|
|
23
|
+
Example with custom DataSource:
|
|
24
|
+
class S3DataSource: # No inheritance needed - duck typing!
|
|
25
|
+
def load(self) -> BinaryIO: ...
|
|
26
|
+
def watch(self, callback) -> Optional[Exception]: ...
|
|
27
|
+
def __str__(self) -> str: ...
|
|
28
|
+
|
|
29
|
+
service = Service()
|
|
30
|
+
service.load_from_data_source(S3DataSource("my-bucket", "key"))
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from ._anonymization import AnonymizingDataSource, AsyncAnonymizingDataSource
|
|
34
|
+
from ._async import AsyncGCSDataSource, AsyncService
|
|
35
|
+
from ._exceptions import (
|
|
36
|
+
ConfigurationError,
|
|
37
|
+
DataLoadError,
|
|
38
|
+
DataSourceError,
|
|
39
|
+
GCSError,
|
|
40
|
+
OrgDataError,
|
|
41
|
+
)
|
|
42
|
+
from ._gcs import GCSDataSource
|
|
43
|
+
from ._log import configure_default_logging, get_logger, set_logger
|
|
44
|
+
from ._redaction import AsyncRedactingDataSource, RedactingDataSource
|
|
45
|
+
from ._service import Service
|
|
46
|
+
from ._types import (
|
|
47
|
+
AliasInfo,
|
|
48
|
+
ChannelInfo,
|
|
49
|
+
Component,
|
|
50
|
+
ComponentOwnerInfo,
|
|
51
|
+
ComponentOwnership,
|
|
52
|
+
ContextItemInfo,
|
|
53
|
+
Data,
|
|
54
|
+
DataSource,
|
|
55
|
+
DataVersion,
|
|
56
|
+
EmailInfo,
|
|
57
|
+
Employee,
|
|
58
|
+
EscalationContactInfo,
|
|
59
|
+
GCSConfig,
|
|
60
|
+
GitHubIDMappings,
|
|
61
|
+
Group,
|
|
62
|
+
GroupType,
|
|
63
|
+
HierarchyNode,
|
|
64
|
+
HierarchyPathEntry,
|
|
65
|
+
Indexes,
|
|
66
|
+
JiraInfo,
|
|
67
|
+
JiraOwnerInfo,
|
|
68
|
+
Lookups,
|
|
69
|
+
MembershipIndex,
|
|
70
|
+
MembershipInfo,
|
|
71
|
+
MembershipType,
|
|
72
|
+
Metadata,
|
|
73
|
+
Org,
|
|
74
|
+
OrgInfo,
|
|
75
|
+
OrgInfoType,
|
|
76
|
+
ParentInfo,
|
|
77
|
+
PIIMode,
|
|
78
|
+
Pillar,
|
|
79
|
+
RepoInfo,
|
|
80
|
+
ResourceInfo,
|
|
81
|
+
RoleInfo,
|
|
82
|
+
SlackConfig,
|
|
83
|
+
SlackIDMappings,
|
|
84
|
+
Team,
|
|
85
|
+
TeamGroup,
|
|
86
|
+
)
|
|
87
|
+
from ._version import (
|
|
88
|
+
API_VERSION,
|
|
89
|
+
__version__,
|
|
90
|
+
__version_info__,
|
|
91
|
+
check_api_compatibility,
|
|
92
|
+
get_version_dict,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"AnonymizingDataSource",
|
|
97
|
+
"AsyncAnonymizingDataSource",
|
|
98
|
+
"AsyncRedactingDataSource",
|
|
99
|
+
"Employee",
|
|
100
|
+
"Team",
|
|
101
|
+
"Org",
|
|
102
|
+
"Pillar",
|
|
103
|
+
"TeamGroup",
|
|
104
|
+
"Group",
|
|
105
|
+
"GroupType",
|
|
106
|
+
"ParentInfo",
|
|
107
|
+
"Data",
|
|
108
|
+
"Metadata",
|
|
109
|
+
"Lookups",
|
|
110
|
+
"Indexes",
|
|
111
|
+
"MembershipIndex",
|
|
112
|
+
"MembershipInfo",
|
|
113
|
+
"HierarchyPathEntry",
|
|
114
|
+
"HierarchyNode",
|
|
115
|
+
"SlackIDMappings",
|
|
116
|
+
"GitHubIDMappings",
|
|
117
|
+
"JiraOwnerInfo",
|
|
118
|
+
"SlackConfig",
|
|
119
|
+
"ChannelInfo",
|
|
120
|
+
"AliasInfo",
|
|
121
|
+
"RoleInfo",
|
|
122
|
+
"JiraInfo",
|
|
123
|
+
"RepoInfo",
|
|
124
|
+
"EmailInfo",
|
|
125
|
+
"EscalationContactInfo",
|
|
126
|
+
"ResourceInfo",
|
|
127
|
+
"Component",
|
|
128
|
+
"ComponentOwnerInfo",
|
|
129
|
+
"ComponentOwnership",
|
|
130
|
+
"ContextItemInfo",
|
|
131
|
+
"OrgInfo",
|
|
132
|
+
"DataVersion",
|
|
133
|
+
"GCSConfig",
|
|
134
|
+
"MembershipType",
|
|
135
|
+
"OrgInfoType",
|
|
136
|
+
"PIIMode",
|
|
137
|
+
"DataSource",
|
|
138
|
+
"RedactingDataSource",
|
|
139
|
+
"Service",
|
|
140
|
+
"AsyncService",
|
|
141
|
+
"GCSDataSource",
|
|
142
|
+
"AsyncGCSDataSource",
|
|
143
|
+
"OrgDataError",
|
|
144
|
+
"DataLoadError",
|
|
145
|
+
"DataSourceError",
|
|
146
|
+
"GCSError",
|
|
147
|
+
"ConfigurationError",
|
|
148
|
+
"get_logger",
|
|
149
|
+
"set_logger",
|
|
150
|
+
"configure_default_logging",
|
|
151
|
+
"__version__",
|
|
152
|
+
"__version_info__",
|
|
153
|
+
"API_VERSION",
|
|
154
|
+
"check_api_compatibility",
|
|
155
|
+
"get_version_dict",
|
|
156
|
+
]
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Data source wrappers for PII anonymization.
|
|
2
|
+
|
|
3
|
+
Replaces real UIDs with random nonces (HUMAN-<hex>) while preserving
|
|
4
|
+
structural relationships, allowing AI to reason about people without
|
|
5
|
+
seeing real identities. Nonce mappings are stable across reloads —
|
|
6
|
+
existing UIDs keep their nonces, new UIDs get fresh random nonces.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import secrets
|
|
11
|
+
from io import BytesIO
|
|
12
|
+
from typing import TYPE_CHECKING, Any, BinaryIO
|
|
13
|
+
|
|
14
|
+
from ._serialization import data_to_json_bytes
|
|
15
|
+
from ._service import parse_data
|
|
16
|
+
from ._types import (
|
|
17
|
+
Data,
|
|
18
|
+
Employee,
|
|
19
|
+
GitHubIDMappings,
|
|
20
|
+
Group,
|
|
21
|
+
MembershipIndex,
|
|
22
|
+
PIIMode,
|
|
23
|
+
SlackIDMappings,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _AnonymizationEngine:
|
|
31
|
+
"""Shared anonymization logic for sync and async data sources.
|
|
32
|
+
|
|
33
|
+
Builds UID-to-nonce lookup tables and rewrites org data JSON
|
|
34
|
+
to replace real identifiers with anonymized nonces.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self._nonce_to_uid: dict[str, str] = {}
|
|
39
|
+
self._uid_to_nonce: dict[str, str] = {}
|
|
40
|
+
self._name_to_nonce: dict[str, str] = {}
|
|
41
|
+
self._nonce_to_display: dict[str, str] = {}
|
|
42
|
+
self._slack_id_to_nonce: dict[str, str] = {}
|
|
43
|
+
self._github_id_to_nonce: dict[str, str] = {}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def uid_to_nonce_map(self) -> dict[str, str]:
|
|
47
|
+
"""Read-only map of real UID -> nonce."""
|
|
48
|
+
return dict(self._uid_to_nonce)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def name_to_nonce_map(self) -> dict[str, str]:
|
|
52
|
+
"""Read-only map of lowered full name -> nonce."""
|
|
53
|
+
return dict(self._name_to_nonce)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def slack_id_to_nonce_map(self) -> dict[str, str]:
|
|
57
|
+
"""Read-only map of Slack user ID -> nonce."""
|
|
58
|
+
return dict(self._slack_id_to_nonce)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def github_id_to_nonce_map(self) -> dict[str, str]:
|
|
62
|
+
"""Read-only map of GitHub ID -> nonce."""
|
|
63
|
+
return dict(self._github_id_to_nonce)
|
|
64
|
+
|
|
65
|
+
def resolve(self, nonce: str) -> str | None:
|
|
66
|
+
"""Resolve a nonce back to a real UID."""
|
|
67
|
+
return self._nonce_to_uid.get(nonce)
|
|
68
|
+
|
|
69
|
+
def anonymize_uid(self, uid: str) -> str | None:
|
|
70
|
+
"""Map a real UID to its nonce."""
|
|
71
|
+
return self._uid_to_nonce.get(uid)
|
|
72
|
+
|
|
73
|
+
def lookup_by_name(self, name: str) -> str | None:
|
|
74
|
+
"""Case-insensitive name lookup returning the nonce."""
|
|
75
|
+
return self._name_to_nonce.get(name.lower())
|
|
76
|
+
|
|
77
|
+
def get_display_name(self, nonce: str) -> str | None:
|
|
78
|
+
"""Get human-readable display for a nonce: 'Full Name (uid)'."""
|
|
79
|
+
return self._nonce_to_display.get(nonce)
|
|
80
|
+
|
|
81
|
+
def _generate_nonce(self, prefix: str, used: set[str]) -> str:
|
|
82
|
+
"""Generate a unique <prefix><hex> nonce."""
|
|
83
|
+
while True:
|
|
84
|
+
nonce = f"{prefix}{secrets.token_hex(4)}"
|
|
85
|
+
if nonce not in used:
|
|
86
|
+
used.add(nonce)
|
|
87
|
+
return nonce
|
|
88
|
+
|
|
89
|
+
def anonymize(self, data: Data) -> Data:
|
|
90
|
+
"""Anonymize org data and rebuild lookup tables. Returns new Data."""
|
|
91
|
+
employees = data.lookups.employees
|
|
92
|
+
|
|
93
|
+
# Preserve existing UID->nonce mappings for UIDs that still exist;
|
|
94
|
+
# generate new random nonces only for new employees.
|
|
95
|
+
prev_uid_to_nonce = self._uid_to_nonce
|
|
96
|
+
|
|
97
|
+
self._nonce_to_uid = {}
|
|
98
|
+
self._uid_to_nonce = {}
|
|
99
|
+
self._name_to_nonce = {}
|
|
100
|
+
self._nonce_to_display = {}
|
|
101
|
+
self._slack_id_to_nonce = {}
|
|
102
|
+
self._github_id_to_nonce = {}
|
|
103
|
+
|
|
104
|
+
used_nonces: set[str] = set(
|
|
105
|
+
prev_uid_to_nonce[uid] for uid in employees if uid in prev_uid_to_nonce
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Phase 1: Build UID -> nonce mapping for all employees
|
|
109
|
+
for uid, emp in employees.items():
|
|
110
|
+
# Reuse existing nonce if available, otherwise generate new one
|
|
111
|
+
nonce = prev_uid_to_nonce.get(uid) or self._generate_nonce(
|
|
112
|
+
"HUMAN-", used_nonces
|
|
113
|
+
)
|
|
114
|
+
self._uid_to_nonce[uid] = nonce
|
|
115
|
+
self._nonce_to_uid[nonce] = uid
|
|
116
|
+
|
|
117
|
+
if emp.full_name:
|
|
118
|
+
self._name_to_nonce[emp.full_name.lower()] = nonce
|
|
119
|
+
self._nonce_to_display[nonce] = emp.full_name
|
|
120
|
+
else:
|
|
121
|
+
self._nonce_to_display[nonce] = nonce
|
|
122
|
+
|
|
123
|
+
# Phase 2: Build Slack ID and GitHub ID nonce mappings.
|
|
124
|
+
# First try the indexes, then fall back to employee records
|
|
125
|
+
# (the indexes may be empty if the data dump didn't populate them).
|
|
126
|
+
slack_uid_to_uid = dict(data.indexes.slack_id_mappings.slack_uid_to_uid)
|
|
127
|
+
github_id_to_uid = dict(data.indexes.github_id_mappings.github_id_to_uid)
|
|
128
|
+
|
|
129
|
+
# Fill from employee records if indexes are incomplete
|
|
130
|
+
for uid, emp in employees.items():
|
|
131
|
+
if emp.slack_uid and emp.slack_uid not in slack_uid_to_uid:
|
|
132
|
+
slack_uid_to_uid[emp.slack_uid] = uid
|
|
133
|
+
if emp.github_id and emp.github_id not in github_id_to_uid:
|
|
134
|
+
github_id_to_uid[emp.github_id] = uid
|
|
135
|
+
|
|
136
|
+
for slack_id, uid in slack_uid_to_uid.items():
|
|
137
|
+
if uid in self._uid_to_nonce:
|
|
138
|
+
slack_nonce = self._generate_nonce("SLACK-", used_nonces)
|
|
139
|
+
self._slack_id_to_nonce[slack_id] = slack_nonce
|
|
140
|
+
self._nonce_to_display[slack_nonce] = slack_id
|
|
141
|
+
for github_id, uid in github_id_to_uid.items():
|
|
142
|
+
if uid in self._uid_to_nonce:
|
|
143
|
+
github_nonce = self._generate_nonce("GITHUB-", used_nonces)
|
|
144
|
+
self._github_id_to_nonce[github_id] = github_nonce
|
|
145
|
+
self._nonce_to_display[github_nonce] = github_id
|
|
146
|
+
|
|
147
|
+
# Build reverse maps: uid -> slack nonce, uid -> github nonce
|
|
148
|
+
uid_to_slack_nonce: dict[str, str] = {}
|
|
149
|
+
for slack_id, uid in slack_uid_to_uid.items():
|
|
150
|
+
if slack_id in self._slack_id_to_nonce:
|
|
151
|
+
uid_to_slack_nonce[uid] = self._slack_id_to_nonce[slack_id]
|
|
152
|
+
uid_to_github_nonce: dict[str, str] = {}
|
|
153
|
+
for github_id, uid in github_id_to_uid.items():
|
|
154
|
+
if github_id in self._github_id_to_nonce:
|
|
155
|
+
uid_to_github_nonce[uid] = self._github_id_to_nonce[github_id]
|
|
156
|
+
|
|
157
|
+
# Phase 3: Rewrite employee records
|
|
158
|
+
new_employees: dict[str, Employee] = {}
|
|
159
|
+
for uid, emp in employees.items():
|
|
160
|
+
nonce = self._uid_to_nonce[uid]
|
|
161
|
+
|
|
162
|
+
manager_nonce = ""
|
|
163
|
+
if emp.manager_uid:
|
|
164
|
+
manager_nonce = self._uid_to_nonce.get(emp.manager_uid, "")
|
|
165
|
+
|
|
166
|
+
new_employees[nonce] = emp.model_copy(
|
|
167
|
+
update={
|
|
168
|
+
"uid": nonce,
|
|
169
|
+
"full_name": "[ANONYMIZED]",
|
|
170
|
+
"email": "[ANONYMIZED]",
|
|
171
|
+
"slack_uid": uid_to_slack_nonce.get(uid, ""),
|
|
172
|
+
"github_id": uid_to_github_nonce.get(uid, ""),
|
|
173
|
+
# Alternate GitHub IDs are real identifiers; clear them so a
|
|
174
|
+
# person cannot be re-identified by a secondary GitHub handle.
|
|
175
|
+
"possible_github_ids": [],
|
|
176
|
+
"manager_uid": manager_nonce,
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# Phase 4: Rewrite indexes
|
|
181
|
+
# Re-key membership index
|
|
182
|
+
new_membership_index: dict[str, Any] = {}
|
|
183
|
+
for uid, memberships in data.indexes.membership.membership_index.items():
|
|
184
|
+
mapped_nonce = self._uid_to_nonce.get(uid)
|
|
185
|
+
if mapped_nonce:
|
|
186
|
+
new_membership_index[mapped_nonce] = memberships
|
|
187
|
+
|
|
188
|
+
# Remap slack index: slack_nonce -> uid_nonce
|
|
189
|
+
new_slack_index: dict[str, str] = {}
|
|
190
|
+
for slack_id, slack_nonce in self._slack_id_to_nonce.items():
|
|
191
|
+
uid_nonce = self._uid_to_nonce.get(slack_uid_to_uid.get(slack_id, ""), "")
|
|
192
|
+
if uid_nonce:
|
|
193
|
+
new_slack_index[slack_nonce] = uid_nonce
|
|
194
|
+
|
|
195
|
+
# Remap github index: github_nonce -> uid_nonce
|
|
196
|
+
new_github_index: dict[str, str] = {}
|
|
197
|
+
for github_id, github_nonce in self._github_id_to_nonce.items():
|
|
198
|
+
uid_nonce = self._uid_to_nonce.get(github_id_to_uid.get(github_id, ""), "")
|
|
199
|
+
if uid_nonce:
|
|
200
|
+
new_github_index[github_nonce] = uid_nonce
|
|
201
|
+
|
|
202
|
+
# Phase 5: Rewrite group people lists in teams/orgs/pillars/team_groups
|
|
203
|
+
new_teams = {
|
|
204
|
+
name: t.model_copy(
|
|
205
|
+
update={"group": _remap_group(t.group, self._uid_to_nonce)}
|
|
206
|
+
)
|
|
207
|
+
for name, t in data.lookups.teams.items()
|
|
208
|
+
}
|
|
209
|
+
new_orgs = {
|
|
210
|
+
name: o.model_copy(
|
|
211
|
+
update={"group": _remap_group(o.group, self._uid_to_nonce)}
|
|
212
|
+
)
|
|
213
|
+
for name, o in data.lookups.orgs.items()
|
|
214
|
+
}
|
|
215
|
+
new_pillars = {
|
|
216
|
+
name: p.model_copy(
|
|
217
|
+
update={"group": _remap_group(p.group, self._uid_to_nonce)}
|
|
218
|
+
)
|
|
219
|
+
for name, p in data.lookups.pillars.items()
|
|
220
|
+
}
|
|
221
|
+
new_team_groups = {
|
|
222
|
+
name: tg.model_copy(
|
|
223
|
+
update={"group": _remap_group(tg.group, self._uid_to_nonce)}
|
|
224
|
+
)
|
|
225
|
+
for name, tg in data.lookups.team_groups.items()
|
|
226
|
+
}
|
|
227
|
+
new_cohorts = {
|
|
228
|
+
name: c.model_copy(
|
|
229
|
+
update={"group": _remap_group(c.group, self._uid_to_nonce)}
|
|
230
|
+
)
|
|
231
|
+
for name, c in data.lookups.cohorts.items()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return data.model_copy(
|
|
235
|
+
update={
|
|
236
|
+
"lookups": data.lookups.model_copy(
|
|
237
|
+
update={
|
|
238
|
+
"employees": new_employees,
|
|
239
|
+
"teams": new_teams,
|
|
240
|
+
"orgs": new_orgs,
|
|
241
|
+
"pillars": new_pillars,
|
|
242
|
+
"team_groups": new_team_groups,
|
|
243
|
+
"cohorts": new_cohorts,
|
|
244
|
+
}
|
|
245
|
+
),
|
|
246
|
+
"indexes": data.indexes.model_copy(
|
|
247
|
+
update={
|
|
248
|
+
"membership": MembershipIndex(
|
|
249
|
+
membership_index=new_membership_index
|
|
250
|
+
),
|
|
251
|
+
"slack_id_mappings": SlackIDMappings(
|
|
252
|
+
slack_uid_to_uid=new_slack_index
|
|
253
|
+
),
|
|
254
|
+
"github_id_mappings": GitHubIDMappings(
|
|
255
|
+
github_id_to_uid=new_github_index
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
),
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _remap_group(group: Group | None, uid_to_nonce: dict[str, str]) -> Group | None:
|
|
264
|
+
"""Return a new Group with people UIDs replaced by nonces (None if no group).
|
|
265
|
+
|
|
266
|
+
UIDs with no mapping (not an employee in this index) are dropped rather than
|
|
267
|
+
passed through: retaining them would emit a real UID in anonymized output.
|
|
268
|
+
"""
|
|
269
|
+
if group is None:
|
|
270
|
+
return None
|
|
271
|
+
new_people = [
|
|
272
|
+
uid_to_nonce[uid]
|
|
273
|
+
for uid in group.resolved_people_uid_list
|
|
274
|
+
if uid in uid_to_nonce
|
|
275
|
+
]
|
|
276
|
+
new_roles = [
|
|
277
|
+
role.model_copy(
|
|
278
|
+
update={
|
|
279
|
+
"people": [
|
|
280
|
+
uid_to_nonce[uid] for uid in role.people if uid in uid_to_nonce
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
for role in group.resolved_roles
|
|
285
|
+
]
|
|
286
|
+
new_escalation = [
|
|
287
|
+
contact.model_copy(
|
|
288
|
+
update={
|
|
289
|
+
"name": "[ANONYMIZED]",
|
|
290
|
+
"url": "",
|
|
291
|
+
"description": "[ANONYMIZED]",
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
for contact in group.escalation
|
|
295
|
+
]
|
|
296
|
+
return group.model_copy(
|
|
297
|
+
update={
|
|
298
|
+
"resolved_people_uid_list": new_people,
|
|
299
|
+
"resolved_roles": new_roles,
|
|
300
|
+
"escalation": new_escalation,
|
|
301
|
+
}
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class AnonymizingDataSource:
|
|
306
|
+
"""DataSource decorator that anonymizes PII in loaded data.
|
|
307
|
+
|
|
308
|
+
When pii_mode is ANONYMIZED, replaces UIDs with random nonces
|
|
309
|
+
and strips PII fields, while preserving structural relationships.
|
|
310
|
+
Nonce tables are rebuilt on every load() call (ephemeral).
|
|
311
|
+
|
|
312
|
+
PII fields anonymized:
|
|
313
|
+
- uid -> HUMAN-<hex> nonce
|
|
314
|
+
- full_name -> "[ANONYMIZED]"
|
|
315
|
+
- email -> "[ANONYMIZED]"
|
|
316
|
+
- slack_uid -> SLACK-<hex> nonce
|
|
317
|
+
- github_id -> GITHUB-<hex> nonce
|
|
318
|
+
- possible_github_ids -> [] (cleared)
|
|
319
|
+
- manager_uid -> mapped HUMAN nonce (consistent)
|
|
320
|
+
|
|
321
|
+
Indexes re-mapped:
|
|
322
|
+
- membership_index keys -> HUMAN nonces
|
|
323
|
+
- resolved_people_uid_list -> HUMAN nonces
|
|
324
|
+
- roles.people -> HUMAN nonces
|
|
325
|
+
- slack_id_mappings: SLACK nonce -> HUMAN nonce
|
|
326
|
+
- github_id_mappings: GITHUB nonce -> HUMAN nonce
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
source: Any,
|
|
332
|
+
pii_mode: PIIMode = PIIMode.FULL,
|
|
333
|
+
) -> None:
|
|
334
|
+
self._source = source
|
|
335
|
+
self._pii_mode = pii_mode
|
|
336
|
+
self._engine = _AnonymizationEngine()
|
|
337
|
+
|
|
338
|
+
def load(self) -> BinaryIO:
|
|
339
|
+
"""Load data, optionally anonymizing PII fields."""
|
|
340
|
+
reader: BinaryIO = self._source.load()
|
|
341
|
+
|
|
342
|
+
if self._pii_mode != PIIMode.ANONYMIZED:
|
|
343
|
+
return reader
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
raw = json.load(reader)
|
|
347
|
+
finally:
|
|
348
|
+
reader.close()
|
|
349
|
+
data = parse_data(raw)
|
|
350
|
+
anonymized = self._engine.anonymize(data)
|
|
351
|
+
return BytesIO(data_to_json_bytes(anonymized))
|
|
352
|
+
|
|
353
|
+
def watch(self, callback: "Callable[[], Exception | None]") -> Exception | None:
|
|
354
|
+
"""Delegate to the underlying data source."""
|
|
355
|
+
result: Exception | None = self._source.watch(callback)
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
def __str__(self) -> str:
|
|
359
|
+
mode_suffix = (
|
|
360
|
+
" [PII anonymized]" if self._pii_mode == PIIMode.ANONYMIZED else ""
|
|
361
|
+
)
|
|
362
|
+
return f"{self._source}{mode_suffix}"
|
|
363
|
+
|
|
364
|
+
# Public API — delegates to engine
|
|
365
|
+
def resolve(self, nonce: str) -> str | None:
|
|
366
|
+
return self._engine.resolve(nonce)
|
|
367
|
+
|
|
368
|
+
def anonymize_uid(self, uid: str) -> str | None:
|
|
369
|
+
return self._engine.anonymize_uid(uid)
|
|
370
|
+
|
|
371
|
+
def lookup_by_name(self, name: str) -> str | None:
|
|
372
|
+
return self._engine.lookup_by_name(name)
|
|
373
|
+
|
|
374
|
+
def get_display_name(self, nonce: str) -> str | None:
|
|
375
|
+
return self._engine.get_display_name(nonce)
|
|
376
|
+
|
|
377
|
+
@property
|
|
378
|
+
def uid_to_nonce_map(self) -> dict[str, str]:
|
|
379
|
+
return self._engine.uid_to_nonce_map
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def name_to_nonce_map(self) -> dict[str, str]:
|
|
383
|
+
return self._engine.name_to_nonce_map
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def slack_id_to_nonce_map(self) -> dict[str, str]:
|
|
387
|
+
return self._engine.slack_id_to_nonce_map
|
|
388
|
+
|
|
389
|
+
@property
|
|
390
|
+
def github_id_to_nonce_map(self) -> dict[str, str]:
|
|
391
|
+
return self._engine.github_id_to_nonce_map
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class AsyncAnonymizingDataSource:
|
|
395
|
+
"""Async DataSource decorator that anonymizes PII in loaded data.
|
|
396
|
+
|
|
397
|
+
Same anonymization as AnonymizingDataSource but with async load().
|
|
398
|
+
Use with AsyncService and async inner sources (AsyncFileDataSource,
|
|
399
|
+
AsyncGCSDataSource).
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
def __init__(
|
|
403
|
+
self,
|
|
404
|
+
source: Any,
|
|
405
|
+
pii_mode: PIIMode = PIIMode.FULL,
|
|
406
|
+
) -> None:
|
|
407
|
+
self._source = source
|
|
408
|
+
self._pii_mode = pii_mode
|
|
409
|
+
self._engine = _AnonymizationEngine()
|
|
410
|
+
|
|
411
|
+
async def load(self) -> BinaryIO:
|
|
412
|
+
"""Load data asynchronously, optionally anonymizing PII fields."""
|
|
413
|
+
reader: BinaryIO = await self._source.load()
|
|
414
|
+
|
|
415
|
+
if self._pii_mode != PIIMode.ANONYMIZED:
|
|
416
|
+
return reader
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
raw = reader.read()
|
|
420
|
+
finally:
|
|
421
|
+
reader.close()
|
|
422
|
+
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
423
|
+
raw_data = json.loads(text)
|
|
424
|
+
data = parse_data(raw_data)
|
|
425
|
+
anonymized = self._engine.anonymize(data)
|
|
426
|
+
return BytesIO(data_to_json_bytes(anonymized))
|
|
427
|
+
|
|
428
|
+
async def watch(self, callback: Any) -> Any:
|
|
429
|
+
"""Delegate to the underlying data source."""
|
|
430
|
+
return await self._source.watch(callback)
|
|
431
|
+
|
|
432
|
+
def __str__(self) -> str:
|
|
433
|
+
mode_suffix = (
|
|
434
|
+
" [PII anonymized]" if self._pii_mode == PIIMode.ANONYMIZED else ""
|
|
435
|
+
)
|
|
436
|
+
return f"{self._source}{mode_suffix}"
|
|
437
|
+
|
|
438
|
+
# Public API — delegates to engine
|
|
439
|
+
def resolve(self, nonce: str) -> str | None:
|
|
440
|
+
return self._engine.resolve(nonce)
|
|
441
|
+
|
|
442
|
+
def anonymize_uid(self, uid: str) -> str | None:
|
|
443
|
+
return self._engine.anonymize_uid(uid)
|
|
444
|
+
|
|
445
|
+
def lookup_by_name(self, name: str) -> str | None:
|
|
446
|
+
return self._engine.lookup_by_name(name)
|
|
447
|
+
|
|
448
|
+
def get_display_name(self, nonce: str) -> str | None:
|
|
449
|
+
return self._engine.get_display_name(nonce)
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def uid_to_nonce_map(self) -> dict[str, str]:
|
|
453
|
+
return self._engine.uid_to_nonce_map
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def name_to_nonce_map(self) -> dict[str, str]:
|
|
457
|
+
return self._engine.name_to_nonce_map
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def slack_id_to_nonce_map(self) -> dict[str, str]:
|
|
461
|
+
return self._engine.slack_id_to_nonce_map
|
|
462
|
+
|
|
463
|
+
@property
|
|
464
|
+
def github_id_to_nonce_map(self) -> dict[str, str]:
|
|
465
|
+
return self._engine.github_id_to_nonce_map
|