bt-cli 0.4.13__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 (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,784 @@
1
+ """Global quick commands for cross-product operations."""
2
+
3
+ from typing import Optional
4
+ import json
5
+ import secrets
6
+ import string
7
+
8
+ import httpx
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from ..core.output import print_api_error, print_error, print_success, print_warning
15
+ from ..core.prompts import prompt_if_missing, prompt_from_list
16
+
17
+
18
+ def generate_password(length: int = 24) -> str:
19
+ """Generate a secure random password."""
20
+ alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
21
+ # Ensure at least one of each character type
22
+ password = [
23
+ secrets.choice(string.ascii_uppercase),
24
+ secrets.choice(string.ascii_lowercase),
25
+ secrets.choice(string.digits),
26
+ secrets.choice("!@#$%^&*"),
27
+ ]
28
+ password.extend(secrets.choice(alphabet) for _ in range(length - 4))
29
+ # Shuffle the password
30
+ password_list = list(password)
31
+ secrets.SystemRandom().shuffle(password_list)
32
+ return "".join(password_list)
33
+
34
+ app = typer.Typer(no_args_is_help=True, help="Cross-product quick commands (Total PASM workflows)")
35
+ console = Console()
36
+
37
+
38
+ @app.command("pasm-onboard")
39
+ def pasm_onboard(
40
+ name: Optional[str] = typer.Option(None, "--name", "-n", help="System/jump item name (used in both PWS and PRA)"),
41
+ ip: Optional[str] = typer.Option(None, "--ip", "-i", help="IP address"),
42
+ dns: Optional[str] = typer.Option(None, "--dns", "-d", help="DNS name (optional)"),
43
+ workgroup: Optional[int] = typer.Option(None, "--workgroup", "-w", help="PWS Workgroup ID"),
44
+ platform: int = typer.Option(2, "--platform", "-p", help="PWS Platform ID (default: 2=Linux)"),
45
+ jumpoint: Optional[int] = typer.Option(None, "--jumpoint", "-j", help="PRA Jumpoint ID"),
46
+ jump_group: Optional[int] = typer.Option(None, "--jump-group", "-g", help="PRA Jump Group ID"),
47
+ account: Optional[str] = typer.Option(None, "--account", "-a", help="Account name to create (prompts if not provided)"),
48
+ password: Optional[str] = typer.Option(None, "--password", help="Account password (auto-generated if not provided)"),
49
+ pra_username: Optional[str] = typer.Option(None, "--pra-username", "-u", help="PRA jump item username (default: ec2-admin)"),
50
+ functional_account: Optional[int] = typer.Option(None, "--functional-account", "-f", help="PWS Functional account ID for auto-management"),
51
+ port: int = typer.Option(22, "--port", help="SSH port (default: 22)"),
52
+ elevation: Optional[str] = typer.Option(None, "--elevation", "-e", help="Elevation command (e.g., 'sudo')"),
53
+ jump_type: str = typer.Option("shell", "--jump-type", "-t", help="PRA jump type: shell or rdp"),
54
+ skip_pws: bool = typer.Option(False, "--skip-pws", help="Skip PWS onboarding (PRA only)"),
55
+ skip_pra: bool = typer.Option(False, "--skip-pra", help="Skip PRA onboarding (PWS only)"),
56
+ skip_account: bool = typer.Option(False, "--skip-account", help="Skip managed account creation in PWS"),
57
+ from_csv: Optional[str] = typer.Option(None, "--from-csv", help="Bulk onboard from CSV file"),
58
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
59
+ ) -> None:
60
+ """Onboard a host for Total PASM (Password Safe + PRA).
61
+
62
+ Creates resources in both Password Safe and PRA with consistent naming
63
+ for ECM (Enterprise Credential Manager) integration.
64
+
65
+ Consistent naming is CRITICAL: The PWS asset name, managed system name,
66
+ and PRA jump item name must all match for ECM credential lookup to work.
67
+
68
+ What gets created:
69
+ - PWS: Asset -> Managed System -> Managed Account
70
+ - PRA: Shell or RDP Jump Item
71
+
72
+ If required options are not provided, prompts interactively.
73
+
74
+ Examples:
75
+ # Interactive mode - prompts for missing info
76
+ bt quick pasm-onboard
77
+
78
+ # Full specification
79
+ bt quick pasm-onboard -n "my-server" -i "10.0.1.50" -w 3 -j 3 -g 24
80
+
81
+ # With functional account for auto-management
82
+ bt quick pasm-onboard -n "web-01" -i "10.0.1.100" -w 3 -j 3 -g 24 -f 7 -e "sudo"
83
+
84
+ # Windows RDP host
85
+ bt quick pasm-onboard -n "win-srv" -i "10.0.2.10" -w 2 -p 1 -j 3 -g 31 --jump-type rdp --port 3389
86
+
87
+ # PRA only (skip PWS)
88
+ bt quick pasm-onboard -n "jump-host" -i "10.0.1.5" -j 3 -g 24 --skip-pws
89
+
90
+ # PWS only (skip PRA)
91
+ bt quick pasm-onboard -n "db-server" -i "10.0.1.60" -w 3 --skip-pra
92
+
93
+ # Bulk onboard from CSV
94
+ bt quick pasm-onboard --from-csv hosts.csv
95
+
96
+ See also:
97
+ bt pws functional list - List functional accounts for auto-management
98
+ bt pws functional create - Create a new functional account
99
+ bt pra jump-groups list - List available jump groups
100
+ bt pra jumpoint list - List available jumpoints
101
+
102
+ CSV format (header row required):
103
+ name,ip,dns,workgroup,jumpoint,jump_group,account,functional_account,port,elevation
104
+ web-01,10.0.1.50,web-01.internal,3,3,24,root,7,22,sudo
105
+ db-01,10.0.1.51,,3,3,24,postgres,7,22,sudo
106
+ """
107
+ import csv
108
+ from pathlib import Path
109
+
110
+ # Handle CSV bulk import
111
+ if from_csv:
112
+ csv_path = Path(from_csv).expanduser()
113
+ if not csv_path.exists():
114
+ print_error(f"CSV file not found: {from_csv}")
115
+ raise typer.Exit(1)
116
+
117
+ console.print(f"[bold]Bulk onboarding from: {from_csv}[/bold]\n")
118
+
119
+ with open(csv_path, newline='') as f:
120
+ reader = csv.DictReader(f)
121
+ rows = list(reader)
122
+
123
+ if not rows:
124
+ print_warning("CSV file is empty")
125
+ raise typer.Exit(0)
126
+
127
+ console.print(f"Found {len(rows)} hosts to onboard:\n")
128
+ for i, row in enumerate(rows, 1):
129
+ console.print(f" {i}. {row.get('name', '?')} ({row.get('ip', '?')})")
130
+
131
+ if not typer.confirm("\nProceed with bulk onboarding?"):
132
+ console.print("[yellow]Cancelled.[/yellow]")
133
+ raise typer.Exit(0)
134
+
135
+ results = []
136
+ for i, row in enumerate(rows, 1):
137
+ console.print(f"\n[bold cyan]--- Onboarding {i}/{len(rows)}: {row.get('name', '?')} ---[/bold cyan]")
138
+ try:
139
+ # Call this function recursively for each row
140
+ _onboard_single_host(
141
+ name=row.get('name'),
142
+ ip=row.get('ip'),
143
+ dns=row.get('dns') or None,
144
+ workgroup=int(row['workgroup']) if row.get('workgroup') else workgroup,
145
+ platform=int(row['platform']) if row.get('platform') else platform,
146
+ jumpoint=int(row['jumpoint']) if row.get('jumpoint') else jumpoint,
147
+ jump_group=int(row['jump_group']) if row.get('jump_group') else jump_group,
148
+ account=row.get('account') or account,
149
+ password=row.get('password') or password,
150
+ pra_username=row.get('pra_username') or pra_username,
151
+ functional_account=int(row['functional_account']) if row.get('functional_account') else functional_account,
152
+ port=int(row['port']) if row.get('port') else port,
153
+ elevation=row.get('elevation') or elevation,
154
+ jump_type=row.get('jump_type') or jump_type,
155
+ skip_pws=skip_pws,
156
+ skip_pra=skip_pra,
157
+ skip_account=skip_account,
158
+ )
159
+ results.append({"name": row.get('name'), "status": "success"})
160
+ except Exception as e:
161
+ console.print(f"[red]Failed: {e}[/red]")
162
+ results.append({"name": row.get('name'), "status": "failed", "error": str(e)})
163
+
164
+ # Summary
165
+ console.print(f"\n[bold]Bulk Onboarding Summary:[/bold]")
166
+ success = sum(1 for r in results if r["status"] == "success")
167
+ failed = sum(1 for r in results if r["status"] == "failed")
168
+ console.print(f" [green]Success: {success}[/green]")
169
+ if failed:
170
+ console.print(f" [red]Failed: {failed}[/red]")
171
+ for r in results:
172
+ if r["status"] == "failed":
173
+ console.print(f" - {r['name']}: {r.get('error', 'Unknown error')}")
174
+
175
+ if output == "json":
176
+ console.print_json(json.dumps(results, default=str))
177
+ return
178
+
179
+ # Single host onboarding
180
+ _onboard_single_host(
181
+ name=name, ip=ip, dns=dns, workgroup=workgroup, platform=platform,
182
+ jumpoint=jumpoint, jump_group=jump_group, account=account, password=password,
183
+ pra_username=pra_username, functional_account=functional_account, port=port,
184
+ elevation=elevation, jump_type=jump_type, skip_pws=skip_pws, skip_pra=skip_pra,
185
+ skip_account=skip_account, output=output,
186
+ )
187
+
188
+
189
+ def _onboard_single_host(
190
+ name: Optional[str],
191
+ ip: Optional[str],
192
+ dns: Optional[str],
193
+ workgroup: Optional[int],
194
+ platform: int,
195
+ jumpoint: Optional[int],
196
+ jump_group: Optional[int],
197
+ account: Optional[str],
198
+ password: Optional[str],
199
+ pra_username: Optional[str],
200
+ functional_account: Optional[int],
201
+ port: int,
202
+ elevation: Optional[str],
203
+ jump_type: str,
204
+ skip_pws: bool,
205
+ skip_pra: bool,
206
+ skip_account: bool,
207
+ output: str = "table",
208
+ ) -> None:
209
+ """Internal function to onboard a single host."""
210
+ try:
211
+ # Import clients
212
+ from ..pws.client.base import get_client as get_pws_client
213
+ from ..pra.client import get_client as get_pra_client
214
+
215
+ result = {
216
+ "name": None,
217
+ "ip": None,
218
+ "pws": {"created": False},
219
+ "pra": {"created": False},
220
+ }
221
+
222
+ # ============================================================
223
+ # PHASE 1: Collect all inputs upfront (interactive prompts)
224
+ # ============================================================
225
+ name = prompt_if_missing(name, "System name (used in both PWS and PRA)")
226
+ ip = prompt_if_missing(ip, "IP address")
227
+
228
+ result["name"] = name
229
+ result["ip"] = ip
230
+
231
+ # PWS prompts
232
+ if not skip_pws:
233
+ with get_pws_client() as pws:
234
+ pws.authenticate()
235
+
236
+ # Prompt for workgroup if missing
237
+ if workgroup is None:
238
+ workgroups = pws.list_workgroups()
239
+ workgroup = prompt_from_list(
240
+ workgroups, "Workgroup ID", "ID", "Name", "Available Workgroups", int
241
+ )
242
+
243
+ # Prompt for account name if not provided and not skipping account
244
+ if not skip_account and account is None:
245
+ account = prompt_if_missing(account, "Account name (e.g., root, admin)")
246
+
247
+ # PRA prompts
248
+ if not skip_pra:
249
+ pra = get_pra_client()
250
+
251
+ # Prompt for jumpoint if missing
252
+ if jumpoint is None:
253
+ jumpoints = pra.list_jumpoints()
254
+ jumpoint = prompt_from_list(
255
+ jumpoints, "Jumpoint ID", "id", "name", "Available Jumpoints", int
256
+ )
257
+
258
+ # Prompt for jump group if missing
259
+ if jump_group is None:
260
+ groups = pra.list_jump_groups()
261
+ jump_group = prompt_from_list(
262
+ groups, "Jump Group ID", "id", "name", "Available Jump Groups", int
263
+ )
264
+
265
+ # ============================================================
266
+ # PHASE 2: Execute operations (no more prompts after this)
267
+ # ============================================================
268
+ console.print() # Blank line before operations
269
+
270
+ # PWS Onboarding
271
+ if not skip_pws:
272
+ with get_pws_client() as pws:
273
+ pws.authenticate()
274
+
275
+ # Create asset
276
+ console.print(f"[dim]PWS: Creating asset '{name}'...[/dim]")
277
+ asset = pws.create_asset(
278
+ workgroup_id=workgroup,
279
+ ip_address=ip,
280
+ asset_name=name,
281
+ dns_name=dns,
282
+ )
283
+ asset_id = asset.get("AssetID")
284
+ console.print(f" [green]Created asset ID: {asset_id}[/green]")
285
+
286
+ # Create managed system
287
+ console.print(f"[dim]PWS: Creating managed system '{name}'...[/dim]")
288
+ system = pws.create_managed_system(
289
+ system_name=name,
290
+ platform_id=platform,
291
+ asset_id=asset_id,
292
+ port=port,
293
+ functional_account_id=functional_account,
294
+ auto_management_flag=True if functional_account else False,
295
+ elevation_command=elevation,
296
+ )
297
+ system_id = system.get("ManagedSystemID")
298
+ console.print(f" [green]Created managed system ID: {system_id}[/green]")
299
+
300
+ result["pws"] = {
301
+ "created": True,
302
+ "asset_id": asset_id,
303
+ "system_id": system_id,
304
+ "workgroup_id": workgroup,
305
+ }
306
+
307
+ # Create managed account (unless skipped)
308
+ if not skip_account:
309
+ # If no functional account (no auto-management), password is required
310
+ # Generate one if not provided
311
+ account_password = password
312
+ generated_password = False
313
+ if not functional_account and not account_password:
314
+ account_password = generate_password()
315
+ generated_password = True
316
+
317
+ console.print(f"[dim]PWS: Creating managed account '{account}'...[/dim]")
318
+ account_obj = pws.create_managed_account(
319
+ system_id=system_id,
320
+ account_name=account,
321
+ password=account_password,
322
+ auto_management_flag=True if functional_account else False,
323
+ )
324
+ account_id = account_obj.get("ManagedAccountID")
325
+ console.print(f" [green]Created managed account ID: {account_id}[/green]")
326
+
327
+ if generated_password:
328
+ console.print(f" [yellow]Generated password (save this!):[/yellow] [bold]{account_password}[/bold]")
329
+
330
+ result["pws"]["account_id"] = account_id
331
+ result["pws"]["account_name"] = account
332
+ result["pws"]["password_generated"] = generated_password
333
+
334
+ # PRA Onboarding
335
+ if not skip_pra:
336
+ pra = get_pra_client()
337
+
338
+ # Determine PRA username
339
+ pra_user = pra_username or ("ec2-admin" if jump_type == "shell" else None)
340
+
341
+ if jump_type == "shell":
342
+ console.print(f"[dim]PRA: Creating shell jump item '{name}'...[/dim]")
343
+ jump_item = pra.create_shell_jump(
344
+ name=name,
345
+ hostname=ip,
346
+ jumpoint_id=jumpoint,
347
+ jump_group_id=jump_group,
348
+ port=port,
349
+ protocol="ssh",
350
+ username=pra_user,
351
+ )
352
+ jump_id = jump_item.get("id")
353
+ console.print(f" [green]Created shell jump ID: {jump_id}[/green]")
354
+ else:
355
+ console.print(f"[dim]PRA: Creating RDP jump item '{name}'...[/dim]")
356
+ jump_item = pra.create_rdp_jump(
357
+ name=name,
358
+ hostname=ip,
359
+ jumpoint_id=jumpoint,
360
+ jump_group_id=jump_group,
361
+ rdp_port=port if port != 22 else 3389,
362
+ )
363
+ jump_id = jump_item.get("id")
364
+ console.print(f" [green]Created RDP jump ID: {jump_id}[/green]")
365
+
366
+ result["pra"] = {
367
+ "created": True,
368
+ "jump_type": jump_type,
369
+ "jump_id": jump_id,
370
+ "jumpoint_id": jumpoint,
371
+ "jump_group_id": jump_group,
372
+ }
373
+
374
+ # Output
375
+ if output == "json":
376
+ console.print_json(json.dumps(result))
377
+ else:
378
+ pws_info = ""
379
+ if result["pws"].get("created"):
380
+ pws_info = (
381
+ f"[green]PWS Created:[/green]\n"
382
+ f" Asset ID: [cyan]{result['pws']['asset_id']}[/cyan]\n"
383
+ f" System ID: [cyan]{result['pws']['system_id']}[/cyan]\n"
384
+ )
385
+ if result["pws"].get("account_id"):
386
+ pws_info += (
387
+ f" Account ID: [cyan]{result['pws']['account_id']}[/cyan]\n"
388
+ f" Account: [bold]{result['pws'].get('account_name', account)}[/bold]\n"
389
+ )
390
+ if result["pws"].get("password_generated"):
391
+ pws_info += f" [yellow](Password was auto-generated)[/yellow]\n"
392
+ pws_info += "\n"
393
+ elif skip_pws:
394
+ pws_info = "[yellow]PWS: Skipped[/yellow]\n\n"
395
+
396
+ pra_info = ""
397
+ if result["pra"].get("created"):
398
+ pra_info = (
399
+ f"[green]PRA Created:[/green]\n"
400
+ f" Jump Type: [cyan]{result['pra']['jump_type'].upper()}[/cyan]\n"
401
+ f" Jump ID: [cyan]{result['pra']['jump_id']}[/cyan]\n"
402
+ f" Jumpoint ID: {result['pra']['jumpoint_id']}\n"
403
+ f" Jump Group ID: {result['pra']['jump_group_id']}\n"
404
+ )
405
+ elif skip_pra:
406
+ pra_info = "[yellow]PRA: Skipped[/yellow]\n"
407
+
408
+ console.print(Panel(
409
+ f"[bold green]Total PASM Onboarding Complete![/bold green]\n\n"
410
+ f"Name: [bold]{name}[/bold]\n"
411
+ f"IP: {ip}\n\n"
412
+ f"{pws_info}"
413
+ f"{pra_info}\n"
414
+ f"[dim]ECM Note: PWS system name and PRA jump item name match for credential lookup.[/dim]",
415
+ title="PASM Onboard",
416
+ ))
417
+
418
+ except httpx.HTTPStatusError as e:
419
+ print_api_error(e, "pasm-onboard")
420
+ raise typer.Exit(1)
421
+ except httpx.RequestError as e:
422
+ print_api_error(e, "pasm-onboard")
423
+ raise typer.Exit(1)
424
+ except typer.Exit:
425
+ raise
426
+ except Exception as e:
427
+ print_api_error(e, "pasm-onboard")
428
+ raise typer.Exit(1)
429
+
430
+
431
+ @app.command("pasm-offboard")
432
+ def pasm_offboard(
433
+ name: str = typer.Option(..., "--name", "-n", help="System/jump item name (searches both PWS and PRA)"),
434
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
435
+ skip_pws: bool = typer.Option(False, "--skip-pws", help="Skip PWS offboarding"),
436
+ skip_pra: bool = typer.Option(False, "--skip-pra", help="Skip PRA offboarding"),
437
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
438
+ ) -> None:
439
+ """Offboard a host from Total PASM (Password Safe + PRA).
440
+
441
+ Removes resources from both Password Safe and PRA by name.
442
+
443
+ What gets deleted:
444
+ - PWS: Managed Accounts -> Managed System -> Asset
445
+ - PRA: Shell or RDP Jump Item
446
+
447
+ Examples:
448
+ bt quick pasm-offboard -n "my-server"
449
+ bt quick pasm-offboard -n "web-01" --force
450
+ bt quick pasm-offboard -n "jump-host" --skip-pws
451
+ """
452
+ try:
453
+ from ..pws.client.base import get_client as get_pws_client
454
+ from ..pra.client import get_client as get_pra_client
455
+
456
+ result = {
457
+ "name": name,
458
+ "pws": {"deleted": False},
459
+ "pra": {"deleted": False},
460
+ }
461
+
462
+ # Find resources in both products
463
+ pws_system = None
464
+ pws_accounts = []
465
+ pws_asset_id = None
466
+ pra_jump = None
467
+ pra_jump_type = None
468
+
469
+ # Search PWS
470
+ if not skip_pws:
471
+ try:
472
+ with get_pws_client() as pws:
473
+ pws.authenticate()
474
+ systems = pws.list_managed_systems(search=name)
475
+ for s in systems:
476
+ if s.get("SystemName", "").lower() == name.lower():
477
+ pws_system = s
478
+ break
479
+ if not pws_system and systems:
480
+ pws_system = systems[0]
481
+
482
+ if pws_system:
483
+ system_id = pws_system.get("ManagedSystemID")
484
+ pws_accounts = pws.list_managed_accounts(system_id=system_id)
485
+ # Get asset ID
486
+ if not pws_system.get("AssetID"):
487
+ full_system = pws.get_managed_system(system_id)
488
+ pws_asset_id = full_system.get("AssetID")
489
+ else:
490
+ pws_asset_id = pws_system.get("AssetID")
491
+ except Exception as e:
492
+ console.print(f"[yellow]PWS search error: {e}[/yellow]")
493
+
494
+ # Search PRA
495
+ if not skip_pra:
496
+ try:
497
+ pra = get_pra_client()
498
+ # Search shell jumps
499
+ shell_jumps = pra.list_shell_jumps()
500
+ for j in shell_jumps:
501
+ if j.get("name", "").lower() == name.lower():
502
+ pra_jump = j
503
+ pra_jump_type = "shell"
504
+ break
505
+
506
+ # Search RDP jumps if not found
507
+ if not pra_jump:
508
+ rdp_jumps = pra.list_rdp_jumps()
509
+ for j in rdp_jumps:
510
+ if j.get("name", "").lower() == name.lower():
511
+ pra_jump = j
512
+ pra_jump_type = "rdp"
513
+ break
514
+ except Exception as e:
515
+ console.print(f"[yellow]PRA search error: {e}[/yellow]")
516
+
517
+ # Show what will be deleted
518
+ console.print(f"\n[bold]Will delete '{name}':[/bold]")
519
+
520
+ if pws_system:
521
+ console.print(f"\n[cyan]PWS:[/cyan]")
522
+ console.print(f" System: {pws_system.get('SystemName')} (ID: {pws_system.get('ManagedSystemID')})")
523
+ if pws_accounts:
524
+ console.print(f" Accounts ({len(pws_accounts)}):")
525
+ for acc in pws_accounts:
526
+ console.print(f" - {acc.get('AccountName')} (ID: {acc.get('ManagedAccountID', acc.get('AccountId'))})")
527
+ if pws_asset_id:
528
+ console.print(f" Asset ID: {pws_asset_id}")
529
+ elif not skip_pws:
530
+ console.print(f"\n[yellow]PWS: No system found matching '{name}'[/yellow]")
531
+
532
+ if pra_jump:
533
+ console.print(f"\n[cyan]PRA:[/cyan]")
534
+ console.print(f" {pra_jump_type.upper()} Jump: {pra_jump.get('name')} (ID: {pra_jump.get('id')})")
535
+ elif not skip_pra:
536
+ console.print(f"\n[yellow]PRA: No jump item found matching '{name}'[/yellow]")
537
+
538
+ if not pws_system and not pra_jump:
539
+ print_error(f"No resources found matching '{name}' in either PWS or PRA")
540
+ raise typer.Exit(1)
541
+
542
+ # Confirm
543
+ if not force:
544
+ confirm = typer.confirm("\nProceed with deletion?")
545
+ if not confirm:
546
+ console.print("[yellow]Cancelled.[/yellow]")
547
+ raise typer.Exit(0)
548
+
549
+ # Delete PWS resources
550
+ if pws_system and not skip_pws:
551
+ with get_pws_client() as pws:
552
+ pws.authenticate()
553
+ system_id = pws_system.get("ManagedSystemID")
554
+
555
+ # Delete accounts
556
+ deleted_accounts = []
557
+ for acc in pws_accounts:
558
+ acc_id = acc.get("ManagedAccountID", acc.get("AccountId"))
559
+ acc_name = acc.get("AccountName")
560
+ console.print(f"[dim]PWS: Deleting account {acc_name}...[/dim]")
561
+ pws.delete_managed_account(acc_id)
562
+ deleted_accounts.append({"id": acc_id, "name": acc_name})
563
+ console.print(f" [green]Deleted account: {acc_name}[/green]")
564
+
565
+ # Delete system
566
+ console.print(f"[dim]PWS: Deleting system {pws_system.get('SystemName')}...[/dim]")
567
+ pws.delete_managed_system(system_id)
568
+ console.print(f" [green]Deleted system: {pws_system.get('SystemName')}[/green]")
569
+
570
+ # Delete asset
571
+ if pws_asset_id:
572
+ console.print(f"[dim]PWS: Deleting asset {pws_asset_id}...[/dim]")
573
+ pws.delete_asset(pws_asset_id)
574
+ console.print(f" [green]Deleted asset ID: {pws_asset_id}[/green]")
575
+
576
+ result["pws"] = {
577
+ "deleted": True,
578
+ "system_id": system_id,
579
+ "asset_id": pws_asset_id,
580
+ "accounts_deleted": len(deleted_accounts),
581
+ }
582
+
583
+ # Delete PRA resources
584
+ if pra_jump and not skip_pra:
585
+ pra = get_pra_client()
586
+ jump_id = pra_jump.get("id")
587
+
588
+ console.print(f"[dim]PRA: Deleting {pra_jump_type} jump item {pra_jump.get('name')}...[/dim]")
589
+ if pra_jump_type == "shell":
590
+ pra.delete_shell_jump(jump_id)
591
+ else:
592
+ pra.delete_rdp_jump(jump_id)
593
+ console.print(f" [green]Deleted {pra_jump_type} jump: {pra_jump.get('name')}[/green]")
594
+
595
+ result["pra"] = {
596
+ "deleted": True,
597
+ "jump_type": pra_jump_type,
598
+ "jump_id": jump_id,
599
+ }
600
+
601
+ # Output
602
+ if output == "json":
603
+ console.print_json(json.dumps(result))
604
+ else:
605
+ console.print(f"\n[bold green]'{name}' offboarded from Total PASM![/bold green]")
606
+ if result["pws"].get("deleted"):
607
+ console.print(f" PWS: System + {result['pws']['accounts_deleted']} accounts + asset deleted")
608
+ if result["pra"].get("deleted"):
609
+ console.print(f" PRA: {result['pra']['jump_type'].upper()} jump item deleted")
610
+
611
+ except httpx.HTTPStatusError as e:
612
+ print_api_error(e, "pasm-offboard")
613
+ raise typer.Exit(1)
614
+ except httpx.RequestError as e:
615
+ print_api_error(e, "pasm-offboard")
616
+ raise typer.Exit(1)
617
+ except typer.Exit:
618
+ raise
619
+ except Exception as e:
620
+ print_api_error(e, "pasm-offboard")
621
+ raise typer.Exit(1)
622
+
623
+
624
+ @app.command("pasm-search")
625
+ def pasm_search(
626
+ query: str = typer.Argument(..., help="Search term (searches both PWS and PRA)"),
627
+ output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
628
+ ) -> None:
629
+ """Search across both Password Safe and PRA.
630
+
631
+ Finds matching systems, accounts, and jump items in both products.
632
+ Useful for finding resources before onboarding or checking ECM alignment.
633
+
634
+ Examples:
635
+ bt quick pasm-search axion
636
+ bt quick pasm-search web-server -o json
637
+ """
638
+ try:
639
+ from ..pws.client.base import get_client as get_pws_client
640
+ from ..pra.client import get_client as get_pra_client
641
+
642
+ query_lower = query.lower()
643
+ result = {
644
+ "query": query,
645
+ "pws": {"systems": [], "accounts": []},
646
+ "pra": {"shell_jumps": [], "rdp_jumps": []},
647
+ }
648
+
649
+ # Search PWS
650
+ try:
651
+ with get_pws_client() as pws:
652
+ pws.authenticate()
653
+ console.print(f"[dim]Searching PWS for '{query}'...[/dim]")
654
+
655
+ systems = pws.list_managed_systems()
656
+ result["pws"]["systems"] = [
657
+ s for s in systems
658
+ if query_lower in s.get("SystemName", "").lower()
659
+ ][:20]
660
+
661
+ accounts = pws.list_managed_accounts(account_name=query)
662
+ result["pws"]["accounts"] = accounts[:20]
663
+ except Exception as e:
664
+ console.print(f"[yellow]PWS search error: {e}[/yellow]")
665
+
666
+ # Search PRA
667
+ try:
668
+ pra = get_pra_client()
669
+ console.print(f"[dim]Searching PRA for '{query}'...[/dim]")
670
+
671
+ shell_jumps = pra.list_shell_jumps()
672
+ result["pra"]["shell_jumps"] = [
673
+ j for j in shell_jumps
674
+ if query_lower in j.get("name", "").lower()
675
+ or query_lower in j.get("hostname", "").lower()
676
+ ][:20]
677
+
678
+ rdp_jumps = pra.list_rdp_jumps()
679
+ result["pra"]["rdp_jumps"] = [
680
+ j for j in rdp_jumps
681
+ if query_lower in j.get("name", "").lower()
682
+ or query_lower in j.get("hostname", "").lower()
683
+ ][:20]
684
+ except Exception as e:
685
+ console.print(f"[yellow]PRA search error: {e}[/yellow]")
686
+
687
+ if output == "json":
688
+ console.print_json(json.dumps(result, default=str))
689
+ else:
690
+ # PWS Systems
691
+ if result["pws"]["systems"]:
692
+ table = Table(title=f"PWS Systems matching '{query}'")
693
+ table.add_column("ID", style="cyan")
694
+ table.add_column("Name", style="green")
695
+ table.add_column("IP", style="yellow")
696
+ table.add_column("Platform")
697
+
698
+ for s in result["pws"]["systems"]:
699
+ table.add_row(
700
+ str(s.get("ManagedSystemID", "")),
701
+ s.get("SystemName", ""),
702
+ s.get("IPAddress", ""),
703
+ str(s.get("PlatformID", "")),
704
+ )
705
+ console.print(table)
706
+ else:
707
+ console.print(f"[yellow]No PWS systems found matching '{query}'[/yellow]")
708
+
709
+ console.print()
710
+
711
+ # PWS Accounts
712
+ if result["pws"]["accounts"]:
713
+ table = Table(title=f"PWS Accounts matching '{query}'")
714
+ table.add_column("ID", style="cyan")
715
+ table.add_column("Account", style="green")
716
+ table.add_column("System", style="yellow")
717
+
718
+ for a in result["pws"]["accounts"]:
719
+ table.add_row(
720
+ str(a.get("ManagedAccountID", a.get("AccountId", ""))),
721
+ a.get("AccountName", ""),
722
+ a.get("SystemName", ""),
723
+ )
724
+ console.print(table)
725
+
726
+ console.print()
727
+
728
+ # PRA Shell Jumps
729
+ if result["pra"]["shell_jumps"]:
730
+ table = Table(title=f"PRA Shell Jumps matching '{query}'")
731
+ table.add_column("ID", style="cyan")
732
+ table.add_column("Name", style="green")
733
+ table.add_column("Hostname", style="yellow")
734
+ table.add_column("Username", style="magenta")
735
+
736
+ for j in result["pra"]["shell_jumps"]:
737
+ table.add_row(
738
+ str(j.get("id", "")),
739
+ j.get("name", ""),
740
+ j.get("hostname", ""),
741
+ j.get("username", "") or "-",
742
+ )
743
+ console.print(table)
744
+ else:
745
+ console.print(f"[yellow]No PRA shell jumps found matching '{query}'[/yellow]")
746
+
747
+ console.print()
748
+
749
+ # PRA RDP Jumps
750
+ if result["pra"]["rdp_jumps"]:
751
+ table = Table(title=f"PRA RDP Jumps matching '{query}'")
752
+ table.add_column("ID", style="cyan")
753
+ table.add_column("Name", style="green")
754
+ table.add_column("Hostname", style="yellow")
755
+ table.add_column("Domain", style="magenta")
756
+
757
+ for j in result["pra"]["rdp_jumps"]:
758
+ table.add_row(
759
+ str(j.get("id", "")),
760
+ j.get("name", ""),
761
+ j.get("hostname", ""),
762
+ j.get("domain", "") or "-",
763
+ )
764
+ console.print(table)
765
+ else:
766
+ console.print(f"[yellow]No PRA RDP jumps found matching '{query}'[/yellow]")
767
+
768
+ # ECM alignment check
769
+ console.print()
770
+ pws_names = {s.get("SystemName", "").lower() for s in result["pws"]["systems"]}
771
+ pra_names = {j.get("name", "").lower() for j in result["pra"]["shell_jumps"]}
772
+ pra_names.update(j.get("name", "").lower() for j in result["pra"]["rdp_jumps"])
773
+
774
+ aligned = pws_names & pra_names
775
+ if aligned:
776
+ console.print(f"[green]ECM Aligned:[/green] {len(aligned)} name(s) match in both PWS and PRA")
777
+ else:
778
+ console.print("[yellow]ECM Note:[/yellow] No exact name matches between PWS and PRA")
779
+
780
+ except typer.Exit:
781
+ raise
782
+ except Exception as e:
783
+ print_api_error(e, "pasm-search")
784
+ raise typer.Exit(1)