protegrity-ai-developer-python 1.2.1__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.
Files changed (53) hide show
  1. appython/__init__.py +12 -0
  2. appython/protector.py +554 -0
  3. appython/service/auth_provider.py +273 -0
  4. appython/service/auth_token_provider.py +45 -0
  5. appython/service/config.py +209 -0
  6. appython/service/payload_builder.py +141 -0
  7. appython/service/request_handler.py +115 -0
  8. appython/service/response_handler.py +78 -0
  9. appython/stats/__init__.py +3 -0
  10. appython/stats/collector.py +90 -0
  11. appython/stats/writer.py +185 -0
  12. appython/utils/codec_helper.py +86 -0
  13. appython/utils/constants.py +246 -0
  14. appython/utils/exceptions.py +141 -0
  15. appython/utils/input_preprocessor.py +325 -0
  16. appython/utils/output_postprocessor.py +99 -0
  17. protegrity_ai_developer_python-1.2.1.dist-info/METADATA +428 -0
  18. protegrity_ai_developer_python-1.2.1.dist-info/RECORD +53 -0
  19. protegrity_ai_developer_python-1.2.1.dist-info/WHEEL +5 -0
  20. protegrity_ai_developer_python-1.2.1.dist-info/entry_points.txt +2 -0
  21. protegrity_ai_developer_python-1.2.1.dist-info/licenses/LICENSE +21 -0
  22. protegrity_ai_developer_python-1.2.1.dist-info/top_level.txt +3 -0
  23. protegrity_developer_python/__init__.py +4 -0
  24. protegrity_developer_python/scan.py +37 -0
  25. protegrity_developer_python/securefind.py +83 -0
  26. protegrity_developer_python/utils/ccn_processing.py +59 -0
  27. protegrity_developer_python/utils/config.py +60 -0
  28. protegrity_developer_python/utils/constants.py +123 -0
  29. protegrity_developer_python/utils/discover.py +49 -0
  30. protegrity_developer_python/utils/logger.py +23 -0
  31. protegrity_developer_python/utils/pii_processing.py +291 -0
  32. protegrity_developer_python/utils/protector.py +23 -0
  33. protegrity_developer_python/utils/semantic_guardrails.py +240 -0
  34. protegrity_developer_python/utils/transform.py +66 -0
  35. pty_migrate/__init__.py +1 -0
  36. pty_migrate/check_cmd.py +871 -0
  37. pty_migrate/cli.py +93 -0
  38. pty_migrate/config.py +127 -0
  39. pty_migrate/create_policy_cmd.py +795 -0
  40. pty_migrate/payloads/__init__.py +51 -0
  41. pty_migrate/payloads/alphabets.json +42 -0
  42. pty_migrate/payloads/dataelements.json +342 -0
  43. pty_migrate/payloads/datastores.json +7 -0
  44. pty_migrate/payloads/deploy_policy_ta.json +1 -0
  45. pty_migrate/payloads/masks.json +18 -0
  46. pty_migrate/payloads/members.json +62 -0
  47. pty_migrate/payloads/policies.json +13 -0
  48. pty_migrate/payloads/roles.json +32 -0
  49. pty_migrate/payloads/rules.json +1639 -0
  50. pty_migrate/payloads/sources.json +10 -0
  51. pty_migrate/payloads/trusted_apps.json +8 -0
  52. pty_migrate/ppc_client.py +371 -0
  53. pty_migrate/stats_cmd.py +87 -0
@@ -0,0 +1,795 @@
1
+ """pty-migrate create-policy — create DE policy on PPC (Team Edition).
2
+
3
+ Reads bundled DE policy payloads and creates resources on PPC.
4
+ By default, filters to only data elements and roles reported by usage stats.
5
+ If no stats exist or --full is specified, creates the complete DE policy.
6
+
7
+ Idempotent: queries PPC for existing resources and only creates the delta.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ import getpass
14
+ from pathlib import Path
15
+
16
+ from pty_migrate.payloads import load_all_payloads
17
+ from pty_migrate.ppc_client import PPCClient
18
+
19
+
20
+ # Resource creation order — dependencies first
21
+ RESOURCE_ORDER = [
22
+ "init",
23
+ "datastores",
24
+ "sources",
25
+ "roles",
26
+ "members",
27
+ "alphabets",
28
+ "masks",
29
+ "dataelements",
30
+ "applications",
31
+ "policies",
32
+ "rules",
33
+ "deploy",
34
+ ]
35
+
36
+
37
+ def _load_stats(stats_file=None):
38
+ """Load usage stats from file. CLI flag > env > config file > default."""
39
+ from pty_migrate.config import resolve
40
+ resolved = resolve(stats_file, "PTY_STATS_FILE", "stats_file",
41
+ str(Path.home() / ".protegrity" / "usage_stats.json"))
42
+ path = Path(resolved)
43
+
44
+ if not path.is_file():
45
+ return None, path
46
+ with open(path, "r") as f:
47
+ return json.load(f), path
48
+
49
+
50
+ def _get_existing_names(client):
51
+ """Query PPC for existing resource names (for delta computation)."""
52
+ return {
53
+ "datastores": {item.get("name", "").lower() for item in client.list_datastores()},
54
+ "sources": {item.get("name", "").lower() for item in client.list_sources()},
55
+ "roles": {item.get("name", "").lower() for item in client.list_roles()},
56
+ "alphabets": {item.get("label", item.get("name", "")).lower() for item in client.list_alphabets()},
57
+ "masks": {item.get("name", "").lower() for item in client.list_masks()},
58
+ "dataelements": {item.get("name", "").lower() for item in client.list_data_elements()},
59
+ "applications": {item.get("name", "").lower() for item in client.list_applications()},
60
+ "policies": {item.get("name", "").lower() for item in client.list_policies()},
61
+ }
62
+
63
+
64
+ def _build_uid_maps(client, payloads):
65
+ """Build mappings from payload positional index → actual PPC UID.
66
+
67
+ The payload files use sequential numeric strings ("1", "2", ...) as UIDs,
68
+ corresponding to the position of each resource in the payload array. On a
69
+ real PPC that already has resources, the actual assigned UIDs differ.
70
+
71
+ This queries the PPC for current resource UIDs by name and builds a mapping.
72
+
73
+ Returns:
74
+ dict with keys: roles, dataelements, sources, datastores, policies, applications
75
+ Each value is a dict mapping payload_index_str → actual_uid_str.
76
+ """
77
+ def _name_to_uid(items, name_key="name"):
78
+ """Build {lowercase_name: uid_str} from a PPC list response."""
79
+ result = {}
80
+ for item in items:
81
+ name = item.get(name_key, "").lower()
82
+ uid = str(item.get("uid", ""))
83
+ if name and uid:
84
+ result[name] = uid
85
+ return result
86
+
87
+ ppc_roles = _name_to_uid(client.list_roles())
88
+ ppc_des = _name_to_uid(client.list_data_elements())
89
+ ppc_sources = _name_to_uid(client.list_sources())
90
+ ppc_datastores = _name_to_uid(client.list_datastores())
91
+ ppc_policies = _name_to_uid(client.list_policies())
92
+ ppc_apps = _name_to_uid(client.list_applications())
93
+ ppc_masks = _name_to_uid(client.list_masks())
94
+
95
+ # Map payload position (1-based) to actual UID by looking up the name
96
+ def _build_map(payload_list, ppc_lookup, name_key="name"):
97
+ mapping = {}
98
+ for i, item in enumerate(payload_list, 1):
99
+ name = item.get(name_key, "").lower()
100
+ actual_uid = ppc_lookup.get(name)
101
+ if actual_uid:
102
+ mapping[str(i)] = actual_uid
103
+ return mapping
104
+
105
+ return {
106
+ "roles": _build_map(payloads["roles"], ppc_roles),
107
+ "dataelements": _build_map(payloads["dataelements"], ppc_des),
108
+ "sources": _build_map(payloads["sources"], ppc_sources),
109
+ "datastores": _build_map(payloads["datastores"], ppc_datastores),
110
+ "policies": _build_map(payloads["policies"], ppc_policies),
111
+ "applications": _build_map(payloads["applications"], ppc_apps),
112
+ "masks": _build_map(payloads["masks"], ppc_masks),
113
+ }
114
+
115
+
116
+ def _filter_payloads_by_stats(payloads, stats):
117
+ """Filter payloads to only include resources reported in usage stats.
118
+
119
+ Filters data elements and roles. Infrastructure resources (datastores,
120
+ sources, alphabets, masks, applications, policies) are always included
121
+ as they are prerequisites.
122
+
123
+ Args:
124
+ payloads: Full DE policy payloads dict.
125
+ stats: Usage stats dict with 'data_elements' and 'policy_users' keys.
126
+
127
+ Returns:
128
+ Filtered payloads dict.
129
+ """
130
+ stats_de_names = {name.lower() for name in stats.get("data_elements", {}).keys()}
131
+ stats_user_names = {name.lower() for name in stats.get("policy_users", {}).keys()}
132
+
133
+ filtered = dict(payloads) # Shallow copy
134
+
135
+ # Filter data elements to those in stats
136
+ if stats_de_names:
137
+ filtered["dataelements"] = [
138
+ de for de in payloads["dataelements"]
139
+ if de["name"].lower() in stats_de_names
140
+ ]
141
+
142
+ # Build role index (1-based position in the full roles list)
143
+ all_roles = payloads["roles"]
144
+ role_name_to_index = {}
145
+ for i, role in enumerate(all_roles, 1):
146
+ role_name_to_index[role["name"].lower()] = str(i)
147
+
148
+ # Determine which roles contain the stats policy_users as members
149
+ # Members is a dict of "roles/{uid}/members" -> [member_payloads]
150
+ roles_containing_users = set() # role indices (str)
151
+ for endpoint, members in payloads["members"].items():
152
+ parts = endpoint.split("/")
153
+ if len(parts) >= 2:
154
+ role_uid = parts[1]
155
+ member_names = {m["name"].lower() for m in members}
156
+ if member_names & stats_user_names:
157
+ roles_containing_users.add(role_uid)
158
+
159
+ # Filter roles to those containing stats policy_users
160
+ if stats_user_names:
161
+ filtered["roles"] = [
162
+ role for role in all_roles
163
+ if role_name_to_index.get(role["name"].lower()) in roles_containing_users
164
+ ]
165
+
166
+ # Filter members to only matching roles, only used members
167
+ filtered_role_names = {r["name"].lower() for r in filtered["roles"]}
168
+ filtered_members = {}
169
+ for endpoint, members in payloads["members"].items():
170
+ parts = endpoint.split("/")
171
+ if len(parts) >= 2:
172
+ role_uid = parts[1]
173
+ if role_uid in roles_containing_users:
174
+ # Only include members that are in stats policy_users
175
+ used_members = [
176
+ m for m in members
177
+ if m["name"].lower() in stats_user_names
178
+ ]
179
+ if used_members:
180
+ filtered_members[endpoint] = used_members
181
+ filtered["members"] = filtered_members
182
+
183
+ # Filter rules to only matching role × data element combinations
184
+ filtered_role_uids = roles_containing_users
185
+
186
+ all_des = payloads["dataelements"]
187
+ de_name_to_index = {}
188
+ for i, de in enumerate(all_des, 1):
189
+ de_name_to_index[de["name"].lower()] = str(i)
190
+
191
+ filtered_de_uids = set()
192
+ for de_name in stats_de_names:
193
+ uid = de_name_to_index.get(de_name)
194
+ if uid:
195
+ filtered_de_uids.add(uid)
196
+
197
+ filtered["rules"] = [
198
+ rule for rule in payloads["rules"]
199
+ if str(rule.get("role")) in filtered_role_uids
200
+ and str(rule.get("dataElement")) in filtered_de_uids
201
+ ]
202
+
203
+ return filtered
204
+
205
+
206
+ def _compute_delta(payloads, existing):
207
+ """Remove resources that already exist on PPC.
208
+
209
+ Args:
210
+ payloads: Filtered payloads to create.
211
+ existing: Dict of existing resource names on PPC (lowercase sets).
212
+
213
+ Returns:
214
+ Payloads with already-existing items removed.
215
+ """
216
+ delta = dict(payloads)
217
+
218
+ delta["datastores"] = [
219
+ p for p in payloads.get("datastores", [])
220
+ if p.get("name", "").lower() not in existing.get("datastores", set())
221
+ ]
222
+ delta["sources"] = [
223
+ p for p in payloads.get("sources", [])
224
+ if p.get("name", "").lower() not in existing.get("sources", set())
225
+ ]
226
+ delta["roles"] = [
227
+ p for p in payloads.get("roles", [])
228
+ if p.get("name", "").lower() not in existing.get("roles", set())
229
+ ]
230
+ delta["alphabets"] = [
231
+ p for p in payloads.get("alphabets", [])
232
+ if p.get("label", p.get("name", "")).lower() not in existing.get("alphabets", set())
233
+ ]
234
+ delta["masks"] = [
235
+ p for p in payloads.get("masks", [])
236
+ if p.get("name", "").lower() not in existing.get("masks", set())
237
+ ]
238
+ delta["dataelements"] = [
239
+ p for p in payloads.get("dataelements", [])
240
+ if p.get("name", "").lower() not in existing.get("dataelements", set())
241
+ ]
242
+ delta["applications"] = [
243
+ p for p in payloads.get("applications", [])
244
+ if p.get("name", "").lower() not in existing.get("applications", set())
245
+ ]
246
+ delta["policies"] = [
247
+ p for p in payloads.get("policies", [])
248
+ if p.get("name", "").lower() not in existing.get("policies", set())
249
+ ]
250
+ # Rules and members are always attempted (PPC handles duplicates via 409)
251
+ delta["rules"] = payloads.get("rules", [])
252
+ delta["members"] = payloads.get("members", {})
253
+ delta["deploy"] = payloads.get("deploy", [])
254
+
255
+ return delta
256
+
257
+
258
+ def _print_plan(delta, dry_run=False):
259
+ """Print what will be created."""
260
+ prefix = "[DRY RUN] " if dry_run else ""
261
+ print(f"\n{prefix}Resources to create:")
262
+ print(f" Datastores: {len(delta.get('datastores', []))}")
263
+ print(f" Sources: {len(delta.get('sources', []))}")
264
+ print(f" Roles: {len(delta.get('roles', []))}")
265
+ members_count = sum(len(v) for v in delta.get("members", {}).values())
266
+ print(f" Members: {members_count}")
267
+ print(f" Alphabets: {len(delta.get('alphabets', []))}")
268
+ print(f" Masks: {len(delta.get('masks', []))}")
269
+ print(f" Data elements: {len(delta.get('dataelements', []))}")
270
+ print(f" Applications: {len(delta.get('applications', []))}")
271
+ print(f" Policies: {len(delta.get('policies', []))}")
272
+ print(f" Rules: {len(delta.get('rules', []))}")
273
+ print()
274
+
275
+ if delta.get("dataelements"):
276
+ de_names = [de["name"] for de in delta["dataelements"]]
277
+ print(f" Data elements: {', '.join(de_names)}")
278
+ if delta.get("roles"):
279
+ role_names = [r["name"] for r in delta["roles"]]
280
+ print(f" Roles: {', '.join(role_names)}")
281
+ print()
282
+
283
+
284
+ def run_create_policy(args):
285
+ """Execute the create-policy command."""
286
+ # Resolve PPC connection from CLI args + env vars + config file.
287
+ # Password precedence: env var > --ppc-password flag > interactive prompt.
288
+ # Env is preferred so secrets don't end up in shell history or `ps`.
289
+ from pty_migrate.config import resolve
290
+ args.ppc_host = resolve(args.ppc_host, "PTY_PPC_HOST", "ppc_host")
291
+ args.ppc_user = resolve(args.ppc_user, "PTY_PPC_USER", "ppc_user", "admin")
292
+ args.ppc_port = resolve(getattr(args, "ppc_port", None), "PTY_PPC_PORT", "ppc_port", 443)
293
+ try:
294
+ args.ppc_port = int(args.ppc_port)
295
+ except (TypeError, ValueError):
296
+ args.ppc_port = 443
297
+
298
+ # Password precedence: --ppc-password flag > PTY_PPC_PASSWORD env
299
+ # > ppc_password in ~/.protegrity/config.yaml (opt-in + chmod 600)
300
+ # > interactive prompt.
301
+ from pty_migrate.config import resolve_password
302
+ cli_password = args.ppc_password
303
+ resolved_pw, pw_source = resolve_password(
304
+ cli_password, "PTY_PPC_PASSWORD", "ppc_password"
305
+ )
306
+ if pw_source == "cli":
307
+ print(" ⚠ --ppc-password on the command line is recorded in shell history.")
308
+ print(" Prefer: export PTY_PPC_PASSWORD='...' (or omit to be prompted).")
309
+ args.ppc_password = resolved_pw
310
+ elif pw_source == "env":
311
+ args.ppc_password = resolved_pw
312
+ if cli_password and cli_password != resolved_pw:
313
+ print(" · Ignoring --ppc-password (PTY_PPC_PASSWORD env var takes precedence).")
314
+ elif pw_source == "file":
315
+ args.ppc_password = resolved_pw
316
+ print(" · PPC password loaded from ~/.protegrity/config.yaml (chmod 600 verified).")
317
+ elif args.ppc_host and sys.stdin.isatty():
318
+ try:
319
+ args.ppc_password = getpass.getpass(
320
+ f"PPC password for '{args.ppc_user}'@{args.ppc_host}: "
321
+ )
322
+ except (EOFError, KeyboardInterrupt):
323
+ print("\n ✗ Aborted.")
324
+ return 1
325
+ else:
326
+ args.ppc_password = None
327
+
328
+ # Workbench password: --flag > env > file (opt-in) > reuse admin password.
329
+ wb_cli = getattr(args, "workbench_password", None)
330
+ resolved_wb, wb_pw_source = resolve_password(
331
+ wb_cli, "PTY_WORKBENCH_PASSWORD", "workbench_password"
332
+ )
333
+ if wb_pw_source == "cli":
334
+ args.workbench_password = resolved_wb
335
+ wb_source = "--workbench-password flag"
336
+ elif wb_pw_source == "env":
337
+ args.workbench_password = resolved_wb
338
+ wb_source = "PTY_WORKBENCH_PASSWORD env var"
339
+ elif wb_pw_source == "file":
340
+ args.workbench_password = resolved_wb
341
+ wb_source = "~/.protegrity/config.yaml"
342
+ else:
343
+ args.workbench_password = args.ppc_password
344
+ wb_source = None # reused from admin — message printed only if we hit the workbench branch
345
+
346
+ args._workbench_password_explicit = wb_source is not None
347
+ args._workbench_password_source = wb_source
348
+
349
+ missing = [name for name, val in
350
+ (("--ppc-host / PTY_PPC_HOST", args.ppc_host),
351
+ ("password (PTY_PPC_PASSWORD env, --ppc-password, or prompt)",
352
+ args.ppc_password))
353
+ if not val]
354
+ if missing:
355
+ print(f" ✗ Missing PPC credentials: {', '.join(missing)}")
356
+ print(" Set the environment variables or pass the matching CLI flags.")
357
+ return 1
358
+
359
+ # 1. Load bundled payloads (full DE policy definitions)
360
+ payloads = load_all_payloads()
361
+
362
+ # 2. Load stats (determines what to filter)
363
+ stats, stats_path = _load_stats(getattr(args, "stats_file", None))
364
+ full_mode = getattr(args, "full", False)
365
+
366
+ # Keep full payloads for UID mapping (rules reference positions in full list)
367
+ full_payloads = payloads
368
+
369
+ if stats and not full_mode:
370
+ print(f"Stats found at: {stats_path}")
371
+ de_names = list(stats.get("data_elements", {}).keys())
372
+ user_names = list(stats.get("policy_users", {}).keys())
373
+ print(f" Data elements in stats: {len(de_names)} — {', '.join(de_names)}")
374
+ print(f" Policy users in stats: {len(user_names)} — {', '.join(user_names)}")
375
+ print()
376
+ payloads = _filter_payloads_by_stats(payloads, stats)
377
+ elif full_mode:
378
+ print("Full mode: creating complete DE policy (ignoring stats filter)")
379
+ print()
380
+ else:
381
+ print(f"No stats found at: {stats_path}")
382
+ print("Creating complete DE policy (all data elements and roles)")
383
+ print()
384
+
385
+ # 3. Dry-run: show plan and exit
386
+ if getattr(args, "dry_run", False):
387
+ _print_plan(payloads, dry_run=True)
388
+ print("[DRY RUN] No changes made on PPC.")
389
+ return 0
390
+
391
+ # 4. Connect to PPC
392
+ user_was_explicit = getattr(args, "_ppc_user_explicit", False)
393
+ print(f"Connecting to PPC at {args.ppc_host}:{args.ppc_port}...")
394
+ client = PPCClient(args.ppc_host, args.ppc_user, args.ppc_password, args.ppc_port)
395
+
396
+ try:
397
+ client.authenticate()
398
+ print(f" ✓ Authenticated as '{args.ppc_user}'")
399
+ except Exception as e:
400
+ if "401" in str(e) and not user_was_explicit:
401
+ # Default user failed, try the alternate
402
+ alt_user = "workbench" if args.ppc_user == "admin" else "admin"
403
+ print(f" · User '{args.ppc_user}' failed, trying '{alt_user}'...")
404
+ client = PPCClient(args.ppc_host, alt_user, args.ppc_password, args.ppc_port)
405
+ try:
406
+ client.authenticate()
407
+ print(f" ✓ Authenticated as '{alt_user}'")
408
+ except Exception as e2:
409
+ print(f" ✗ Authentication failed for both '{args.ppc_user}' and '{alt_user}': {e2}")
410
+ return 1
411
+ else:
412
+ print(f" ✗ Authentication failed: {e}")
413
+ return 1
414
+
415
+ # 4b. Ensure PIM-capable user
416
+ # PIM APIs require 'workbench_management_policy_write' permission
417
+ # (via 'workbench_administrator' role). If the current user lacks PIM
418
+ # access, attempt to set up the 'workbench' user automatically.
419
+ pim_test = client._get("/v2/pim/datastores")
420
+ if pim_test.status_code == 403:
421
+ if user_was_explicit:
422
+ # User explicitly chose this user — don't second-guess
423
+ print(f"\n ✗ User '{client._user}' lacks PIM permissions (HTTP 403)")
424
+ print(f" The PIM API requires the 'workbench_administrator' role.")
425
+ print(f" Use --ppc-user with a PIM-capable user, or omit --ppc-user")
426
+ print(f" to let the script auto-create the 'workbench' user.")
427
+ return 1
428
+
429
+ print(f"\n · User '{client._user}' lacks PIM permissions, setting up 'workbench' user...")
430
+ wb_password = args.workbench_password
431
+ if args._workbench_password_explicit:
432
+ print(f" Using workbench password from {args._workbench_password_source}.")
433
+ else:
434
+ print(f" Using same password as '{args.ppc_user}' for workbench user")
435
+ print(f" (override with --workbench-password or PTY_WORKBENCH_PASSWORD).")
436
+ wb_exists = client.user_exists("workbench")
437
+ if not wb_exists:
438
+ created, resp = client.create_user(
439
+ "workbench", wb_password,
440
+ roles=["workbench_administrator"]
441
+ )
442
+ if created:
443
+ print(f" ✓ Created 'workbench' user with workbench_administrator role")
444
+ else:
445
+ print(f" ✗ Failed to create workbench user: {resp.status_code} {resp.text}")
446
+ return 1
447
+ else:
448
+ print(f" · 'workbench' user already exists")
449
+
450
+ # Ensure workbench_administrator role has all required PIM permissions
451
+ wb_permissions = [
452
+ "workbench_management_policy_write",
453
+ "workbench_management_policy_read",
454
+ "workbench_deployment_immutablepackage_export",
455
+ "workbench_deployment_certificate_export",
456
+ "cli_access",
457
+ "can_create_token",
458
+ ]
459
+ if client.ensure_role_permissions("workbench_administrator", wb_permissions):
460
+ print(f" ✓ Role 'workbench_administrator' permissions confirmed")
461
+ else:
462
+ print(f" · Could not update role permissions (non-critical)")
463
+
464
+ # Re-authenticate as workbench
465
+ try:
466
+ client.re_authenticate("workbench", wb_password)
467
+ print(f" ✓ Re-authenticated as 'workbench'")
468
+ except Exception as e:
469
+ if wb_exists:
470
+ # workbench was pre-existing with a different password
471
+ print(f" ✗ Cannot authenticate as 'workbench' (password mismatch)")
472
+ if args._workbench_password_explicit:
473
+ print(f" The provided workbench password is wrong; pass the correct one.")
474
+ else:
475
+ print(f" The 'workbench' user pre-exists with a different password than '{args.ppc_user}'.")
476
+ print(f" Re-run with --workbench-password '<workbench-password>' or")
477
+ print(f" export PTY_WORKBENCH_PASSWORD='<workbench-password>'.")
478
+ else:
479
+ print(f" ✗ Failed to authenticate as newly created 'workbench': {e}")
480
+ return 1
481
+
482
+ # 5. Initialize PIM if needed
483
+ print("\nChecking PIM initialization...")
484
+ try:
485
+ client.init_pim()
486
+ print(" ✓ PIM ready")
487
+ except Exception as e:
488
+ print(f" ✗ PIM initialization failed: {e}")
489
+ return 1
490
+
491
+ # 6. Query existing resources (for delta computation)
492
+ print("Querying existing resources on PPC...")
493
+ existing = _get_existing_names(client)
494
+ print(f" Found: {len(existing['dataelements'])} DEs, {len(existing['roles'])} roles, "
495
+ f"{len(existing['policies'])} policies")
496
+
497
+ # 7. Compute delta
498
+ delta = _compute_delta(payloads, existing)
499
+ _print_plan(delta)
500
+
501
+ total_new = (
502
+ len(delta["datastores"]) + len(delta["sources"]) + len(delta["roles"])
503
+ + len(delta["alphabets"]) + len(delta["masks"]) + len(delta["dataelements"])
504
+ + len(delta["applications"]) + len(delta["policies"])
505
+ )
506
+ if total_new == 0 and not delta["rules"] and not delta["members"]:
507
+ if not delta.get("deploy"):
508
+ print("Nothing new to create. PPC already has all required resources.")
509
+ return 0
510
+ print("All resources exist. Verifying deployment...")
511
+ print()
512
+
513
+ # 8. Create resources in dependency order
514
+ created = 0
515
+ skipped = 0
516
+ errors = 0
517
+
518
+ # Datastores
519
+ for payload in delta["datastores"]:
520
+ ok, _ = client.create_datastore(payload)
521
+ if ok:
522
+ created += 1
523
+ print(f" ✓ Created datastore: {payload.get('name')}")
524
+ else:
525
+ skipped += 1
526
+
527
+ # Sources
528
+ for payload in delta["sources"]:
529
+ ok, _ = client.create_source(payload)
530
+ if ok:
531
+ created += 1
532
+ print(f" ✓ Created source: {payload.get('name')}")
533
+ else:
534
+ skipped += 1
535
+
536
+ # Roles
537
+ for payload in delta["roles"]:
538
+ ok, _ = client.create_role(payload)
539
+ if ok:
540
+ created += 1
541
+ print(f" ✓ Created role: {payload.get('name')}")
542
+ else:
543
+ skipped += 1
544
+
545
+ # Alphabets
546
+ for payload in delta["alphabets"]:
547
+ ok, _ = client.create_alphabet(payload)
548
+ if ok:
549
+ created += 1
550
+ print(f" ✓ Created alphabet: {payload.get('label', payload.get('name'))}")
551
+ else:
552
+ skipped += 1
553
+
554
+ # Masks
555
+ for payload in delta["masks"]:
556
+ ok, resp = client.create_mask(payload)
557
+ if ok:
558
+ created += 1
559
+ print(f" ✓ Created mask: {payload.get('name')}")
560
+ else:
561
+ skipped += 1
562
+
563
+ # Build mask position→UID map so DEs that reference a mask via noEnc.maskUid
564
+ # (stored as positional index in our payload) can be remapped to the actual
565
+ # PPC-assigned UID before upload. Same shape as _build_uid_maps()["masks"]
566
+ # but needed earlier in the flow (before DE upload).
567
+ _ppc_masks_now = {
568
+ item.get("name", "").lower(): str(item.get("uid", ""))
569
+ for item in client.list_masks()
570
+ if item.get("name") and item.get("uid")
571
+ }
572
+ _mask_pos_to_uid = {}
573
+ for i, m in enumerate(full_payloads.get("masks", []), 1):
574
+ actual = _ppc_masks_now.get(m.get("name", "").lower())
575
+ if actual:
576
+ _mask_pos_to_uid[str(i)] = actual
577
+
578
+ # Same for alphabets — DEs reference them via unicodeGen2Token.alphabetUid
579
+ # as positional indexes into local alphabets.json.
580
+ _ppc_alphas_now = {
581
+ (item.get("label") or item.get("name") or "").lower(): str(item.get("uid", ""))
582
+ for item in client.list_alphabets()
583
+ if item.get("uid")
584
+ }
585
+ _alpha_pos_to_uid = {}
586
+ for i, a in enumerate(full_payloads.get("alphabets", []), 1):
587
+ key = (a.get("label") or a.get("name") or "").lower()
588
+ actual = _ppc_alphas_now.get(key)
589
+ if actual:
590
+ _alpha_pos_to_uid[str(i)] = actual
591
+
592
+ # Data elements
593
+ for payload in delta["dataelements"]:
594
+ remapped = payload
595
+ no_enc = payload.get("noEnc")
596
+ u2 = payload.get("unicodeGen2Token")
597
+ if isinstance(no_enc, dict) and "maskUid" in no_enc:
598
+ remapped = dict(payload)
599
+ remapped["noEnc"] = dict(no_enc)
600
+ remapped["noEnc"]["maskUid"] = _mask_pos_to_uid.get(
601
+ str(no_enc["maskUid"]), str(no_enc["maskUid"])
602
+ )
603
+ if isinstance(u2, dict) and "alphabetUid" in u2:
604
+ if remapped is payload:
605
+ remapped = dict(payload)
606
+ new_u2 = dict(u2)
607
+ new_u2["alphabetUid"] = _alpha_pos_to_uid.get(
608
+ str(u2["alphabetUid"]), str(u2["alphabetUid"])
609
+ )
610
+ remapped["unicodeGen2Token"] = new_u2
611
+ ok, resp = client.create_data_element(remapped)
612
+ if ok:
613
+ created += 1
614
+ print(f" ✓ Created data element: {payload.get('name')}")
615
+ else:
616
+ skipped += 1
617
+
618
+ # Applications
619
+ for payload in delta["applications"]:
620
+ ok, _ = client.create_application(payload)
621
+ if ok:
622
+ created += 1
623
+ print(f" ✓ Created application: {payload.get('name')}")
624
+ else:
625
+ skipped += 1
626
+
627
+ # Policies
628
+ for payload in delta["policies"]:
629
+ ok, _ = client.create_policy(payload)
630
+ if ok:
631
+ created += 1
632
+ print(f" ✓ Created policy: {payload.get('name')}")
633
+ else:
634
+ skipped += 1
635
+
636
+ # 9. Build UID mappings — payload positional index → actual PPC UID
637
+ # Must use full_payloads (not filtered) because rules reference positions
638
+ # in the original full payload arrays (e.g., dataElement "8" = city at
639
+ # position 8 in the full DE list).
640
+ print("\nResolving resource UIDs...")
641
+ uid_maps = _build_uid_maps(client, full_payloads)
642
+
643
+ # Members — uses remapped role/source UIDs
644
+ for endpoint, members in delta.get("members", {}).items():
645
+ # Remap role UID in endpoint: "roles/{payload_uid}/members" → actual UID
646
+ parts = endpoint.split("/")
647
+ if len(parts) >= 2 and parts[0] == "roles":
648
+ actual_role_uid = uid_maps["roles"].get(parts[1], parts[1])
649
+ endpoint = f"roles/{actual_role_uid}/members"
650
+ for member in members:
651
+ # Remap source UID in member payload
652
+ remapped_member = dict(member)
653
+ if "source" in remapped_member:
654
+ remapped_member["source"] = uid_maps["sources"].get(
655
+ remapped_member["source"], remapped_member["source"]
656
+ )
657
+ ok, _ = client.post_resource(endpoint, [remapped_member])
658
+ if ok:
659
+ created += 1
660
+ print(f" ✓ Added member: {member.get('name')} → {endpoint}")
661
+ else:
662
+ skipped += 1
663
+
664
+ # Rules — remap role/dataElement UIDs to actual PPC UIDs
665
+ policy_uid = uid_maps["policies"].get("1", "1") # Our policy is position 1
666
+ # DEs that use aes256CbcEnc don't support noAccessOperation=PROTECTED_VALUE.
667
+ # Strip that field from rules targeting them. Positions are 1-based to
668
+ # match the dataElement references in rules.json.
669
+ cbc_de_positions = {
670
+ str(i + 1)
671
+ for i, de in enumerate(full_payloads.get("dataelements", []))
672
+ if isinstance(de, dict) and "aes256CbcEnc" in de
673
+ }
674
+ rules_created = 0
675
+ rules_skipped = 0
676
+ rules_failed = 0
677
+ first_error = None
678
+ for rule in delta["rules"]:
679
+ remapped_rule = dict(rule)
680
+ if (
681
+ str(rule.get("dataElement")) in cbc_de_positions
682
+ and "noAccessOperation" in remapped_rule
683
+ ):
684
+ remapped_rule.pop("noAccessOperation", None)
685
+ remapped_rule["role"] = uid_maps["roles"].get(
686
+ str(rule.get("role")), str(rule.get("role"))
687
+ )
688
+ remapped_rule["dataElement"] = uid_maps["dataelements"].get(
689
+ str(rule.get("dataElement")), str(rule.get("dataElement"))
690
+ )
691
+ if "mask" in remapped_rule:
692
+ remapped_rule["mask"] = uid_maps["masks"].get(
693
+ str(rule.get("mask")), str(rule.get("mask"))
694
+ )
695
+ ok, resp = client.create_rule(policy_uid, remapped_rule)
696
+ if ok:
697
+ rules_created += 1
698
+ elif resp and resp.status_code == 400:
699
+ rules_failed += 1
700
+ if first_error is None:
701
+ first_error = resp.text[:200]
702
+ else:
703
+ rules_skipped += 1
704
+ if rules_created:
705
+ print(f" ✓ Created {rules_created} rules")
706
+ if rules_skipped:
707
+ print(f" · {rules_skipped} rules already existed")
708
+ if rules_failed:
709
+ print(f" ✗ {rules_failed} rules failed (400 Bad Request)")
710
+ print(f" First error: {first_error}")
711
+
712
+ # Deploy — use actual datastore and policy UIDs
713
+ ds_uid = uid_maps["datastores"].get("1", "1") # Our datastore is position 1
714
+ app_uid = uid_maps["applications"].get("1") # None if app didn't resolve
715
+ if delta.get("deploy"):
716
+ # Only include the application in the deploy if we have a real PPC UID
717
+ # for it. If app creation failed (e.g. description validation) and PPC
718
+ # has no pre-existing app with the same name, skip it from the start to
719
+ # avoid a spurious "Application 'N' does not exist" error.
720
+ if app_uid:
721
+ deploy_payload = {"policies": [policy_uid], "applications": [app_uid]}
722
+ else:
723
+ deploy_payload = {"policies": [policy_uid], "applications": []}
724
+ ok, resp = client.deploy(ds_uid, deploy_payload)
725
+ if ok:
726
+ msg = f" ✓ Policy deployed to datastore (ds={ds_uid}, policy={policy_uid})"
727
+ print(msg)
728
+ if not app_uid:
729
+ print(" Note: application not included (creation failed or not found)")
730
+ else:
731
+ # Application might already be assigned to another datastore; retry without it
732
+ deploy_payload_no_app = {"policies": [policy_uid], "applications": []}
733
+ ok2, resp2 = client.deploy(ds_uid, deploy_payload_no_app)
734
+ if ok2:
735
+ print(f" ✓ Policy deployed to datastore (ds={ds_uid}, policy={policy_uid})")
736
+ print(f" Note: application not included (already assigned elsewhere)")
737
+ else:
738
+ # Use the most relevant response for error reporting
739
+ err_resp = resp2 if resp2 is not None else resp
740
+ status = getattr(err_resp, 'status_code', '?') if err_resp else '?'
741
+ body = ''
742
+ if err_resp is not None:
743
+ try:
744
+ body = err_resp.text[:200]
745
+ except Exception:
746
+ pass
747
+ print(f" ✗ Deployment failed (HTTP {status}): {body}")
748
+ errors += 1
749
+
750
+ # Summary
751
+ print(f"\n{'─' * 50}")
752
+ print(f"Summary: {created} created, {skipped} skipped (already existed)")
753
+ if errors:
754
+ print(f" {errors} errors")
755
+ return 1
756
+ print("Done.")
757
+
758
+ # Next steps: export key guidance
759
+ # Re-resolve the datastore UID from PPC so the printed curl reflects the
760
+ # actual id (e.g. after a reset the same logical datastore can land at id=2).
761
+ ds_uid_for_next_steps = ds_uid
762
+ target_ds_name = ""
763
+ if full_payloads.get("datastores"):
764
+ target_ds_name = full_payloads["datastores"][0].get("name", "").lower()
765
+ actual_datastores = client.list_datastores()
766
+ matched = next(
767
+ (d for d in actual_datastores
768
+ if d.get("name", "").lower() == target_ds_name and d.get("uid")),
769
+ None,
770
+ )
771
+ if matched:
772
+ ds_uid_for_next_steps = str(matched["uid"])
773
+ elif actual_datastores:
774
+ first_uid = actual_datastores[0].get("uid")
775
+ if first_uid:
776
+ ds_uid_for_next_steps = str(first_uid)
777
+
778
+ print(f"\n{'─' * 50}")
779
+ print(f"Next steps — Policy Agent setup:")
780
+ print(f"")
781
+ print(f" 1. Add the KMS export key to the datastore so the Policy Agent can")
782
+ print(f" export the encrypted policy package:")
783
+ print(f"")
784
+ print(f" curl -k -H \"Authorization: Bearer $TOKEN\" \\")
785
+ print(f" -H \"Content-Type: application/json\" \\")
786
+ print(f" -X POST https://{args.ppc_host}/pty/v2/pim/datastores/{ds_uid_for_next_steps}/export/keys \\")
787
+ print(f" -d '{{\"algorithm\":\"RSA-OAEP-256\",\"pem\":\"<KMS-PUBLIC-KEY-PEM>\"}}'")
788
+ print(f"")
789
+ print(f" The fingerprint returned must match PTY_DATASTORE_KEY on the Policy Agent Lambda.")
790
+ print(f"")
791
+ print(f" 2. Trigger the Policy Agent Lambda (or wait for the hourly CRON schedule).")
792
+ print(f"")
793
+ print(f" 3. Run: pty-migrate check")
794
+ print()
795
+ return 0