bt-cli 0.4.13__py3-none-any.whl → 0.4.15__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.
- bt_cli/core/config_file.py +14 -3
- bt_cli/epmw/commands/policies.py +17 -3
- bt_cli/pws/commands/import_export.py +280 -105
- bt_cli/pws/commands/secrets.py +5 -5
- {bt_cli-0.4.13.dist-info → bt_cli-0.4.15.dist-info}/METADATA +1 -1
- {bt_cli-0.4.13.dist-info → bt_cli-0.4.15.dist-info}/RECORD +8 -8
- {bt_cli-0.4.13.dist-info → bt_cli-0.4.15.dist-info}/WHEEL +0 -0
- {bt_cli-0.4.13.dist-info → bt_cli-0.4.15.dist-info}/entry_points.txt +0 -0
bt_cli/core/config_file.py
CHANGED
|
@@ -205,6 +205,7 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
|
|
|
205
205
|
# This prevents TOCTOU race where file could be readable between
|
|
206
206
|
# creation and chmod.
|
|
207
207
|
import os
|
|
208
|
+
import sys
|
|
208
209
|
import tempfile
|
|
209
210
|
|
|
210
211
|
# Write to temp file in same directory, then atomic rename
|
|
@@ -213,12 +214,22 @@ def save_config_file(config: ConfigFile, path: Optional[Path] = None) -> None:
|
|
|
213
214
|
# Create temp file with secure permissions
|
|
214
215
|
fd, tmp_path = tempfile.mkstemp(dir=dir_path, prefix=".config_", suffix=".tmp")
|
|
215
216
|
try:
|
|
216
|
-
# Set permissions on file descriptor before writing
|
|
217
|
-
|
|
217
|
+
# Set permissions on file descriptor before writing (POSIX only)
|
|
218
|
+
# Windows doesn't support fchmod - permissions work differently there
|
|
219
|
+
if sys.platform != "win32" and hasattr(os, "fchmod"):
|
|
220
|
+
os.fchmod(fd, 0o600)
|
|
218
221
|
with os.fdopen(fd, "w") as f:
|
|
219
222
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
220
|
-
# Atomic rename
|
|
223
|
+
# Atomic rename (on Windows, need to remove target first if exists)
|
|
224
|
+
if sys.platform == "win32" and path.exists():
|
|
225
|
+
os.unlink(path)
|
|
221
226
|
os.rename(tmp_path, path)
|
|
227
|
+
# On Windows, set permissions after the fact using chmod
|
|
228
|
+
if sys.platform == "win32":
|
|
229
|
+
try:
|
|
230
|
+
os.chmod(path, 0o600)
|
|
231
|
+
except OSError:
|
|
232
|
+
pass # Best effort on Windows
|
|
222
233
|
except Exception:
|
|
223
234
|
# Clean up temp file on error
|
|
224
235
|
try:
|
bt_cli/epmw/commands/policies.py
CHANGED
|
@@ -288,15 +288,29 @@ def revert_policy(
|
|
|
288
288
|
@app.command("download")
|
|
289
289
|
def download_policy(
|
|
290
290
|
policy_id: str = typer.Argument(..., help="Policy ID (UUID)"),
|
|
291
|
+
file: Optional[str] = typer.Option(None, "--file", "-f", help="Save to file instead of stdout"),
|
|
291
292
|
):
|
|
292
|
-
"""Download policy content (XML format).
|
|
293
|
+
"""Download policy content (XML format).
|
|
294
|
+
|
|
295
|
+
Examples:
|
|
296
|
+
bt epmw policies download <policy_id>
|
|
297
|
+
bt epmw policies download <policy_id> --file policy.xml
|
|
298
|
+
bt epmw policies download <policy_id> > policy.xml
|
|
299
|
+
"""
|
|
293
300
|
from bt_cli.epmw.client import get_client
|
|
294
301
|
|
|
295
302
|
try:
|
|
296
303
|
client = get_client()
|
|
297
304
|
content = client.download_policy(policy_id)
|
|
298
|
-
|
|
299
|
-
|
|
305
|
+
|
|
306
|
+
if file:
|
|
307
|
+
# Write to file
|
|
308
|
+
with open(file, "w", encoding="utf-8") as f:
|
|
309
|
+
f.write(content)
|
|
310
|
+
print_success(f"Policy saved to: {file}")
|
|
311
|
+
else:
|
|
312
|
+
# Output to stdout
|
|
313
|
+
typer.echo(content)
|
|
300
314
|
except httpx.HTTPStatusError as e:
|
|
301
315
|
print_api_error(e, "download policy")
|
|
302
316
|
raise typer.Exit(1)
|
|
@@ -16,10 +16,10 @@ import_app = typer.Typer(no_args_is_help=True, help="Import resources from CSV f
|
|
|
16
16
|
export_app = typer.Typer(no_args_is_help=True, help="Export sample CSV templates")
|
|
17
17
|
console = Console()
|
|
18
18
|
|
|
19
|
-
# Column definitions for CSV formats
|
|
19
|
+
# Column definitions for CSV formats - using NAMES not IDs for readability
|
|
20
20
|
SYSTEMS_COLUMNS = [
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"system_name", "ip_address", "workgroup", "platform", "port",
|
|
22
|
+
"functional_account", "elevation_command", "auto_manage",
|
|
23
23
|
"account_name", "account_password", "account_description"
|
|
24
24
|
]
|
|
25
25
|
|
|
@@ -27,31 +27,36 @@ SECRETS_COLUMNS = [
|
|
|
27
27
|
"folder_path", "title", "username", "password", "description", "notes"
|
|
28
28
|
]
|
|
29
29
|
|
|
30
|
-
# Sample data for templates
|
|
30
|
+
# Sample data for templates - uses names that will be resolved to IDs
|
|
31
|
+
# NOTE: account_password only required if auto_manage=false
|
|
32
|
+
# If auto_manage=true with functional_account, PWS rotates password automatically
|
|
31
33
|
SYSTEMS_SAMPLE = [
|
|
32
34
|
{
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
+
"system_name": "linux-managed-01", "ip_address": "10.0.1.50",
|
|
36
|
+
"workgroup": "Default", # Use workgroup NAME (run: bt pws workgroups list)
|
|
37
|
+
"platform": "Linux SSH", # Use platform NAME (run: bt pws platforms list)
|
|
38
|
+
"port": "22",
|
|
39
|
+
"functional_account": "Linux - Local", # FA DisplayName (run: bt pws functional list)
|
|
35
40
|
"elevation_command": "sudo", "auto_manage": "true",
|
|
36
|
-
"account_name": "root", "account_password": "", "account_description": "
|
|
41
|
+
"account_name": "root", "account_password": "", "account_description": "Auto-managed root"
|
|
37
42
|
},
|
|
38
43
|
{
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"elevation_command": "sudo", "auto_manage": "true",
|
|
42
|
-
"account_name": "
|
|
44
|
+
"system_name": "linux-managed-01", "ip_address": "10.0.1.50",
|
|
45
|
+
"workgroup": "Default", "platform": "Linux SSH", "port": "22",
|
|
46
|
+
"functional_account": "Linux - Local", "elevation_command": "sudo", "auto_manage": "true",
|
|
47
|
+
"account_name": "appuser", "account_password": "", "account_description": "Auto-managed app account"
|
|
43
48
|
},
|
|
44
49
|
{
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"elevation_command": "
|
|
48
|
-
"account_name": "
|
|
50
|
+
"system_name": "linux-manual-01", "ip_address": "10.0.1.60",
|
|
51
|
+
"workgroup": "Default", "platform": "Linux SSH", "port": "22",
|
|
52
|
+
"functional_account": "", "elevation_command": "", "auto_manage": "false",
|
|
53
|
+
"account_name": "dbadmin", "account_password": "InitialP@ss123!", "account_description": "Manual - password required"
|
|
49
54
|
},
|
|
50
55
|
{
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"elevation_command": "", "auto_manage": "false",
|
|
54
|
-
"account_name": "Administrator", "account_password": "
|
|
56
|
+
"system_name": "win-manual-01", "ip_address": "10.0.2.10",
|
|
57
|
+
"workgroup": "Default", "platform": "Windows", "port": "5985",
|
|
58
|
+
"functional_account": "", "elevation_command": "", "auto_manage": "false",
|
|
59
|
+
"account_name": "Administrator", "account_password": "WinP@ss456!", "account_description": "Manual - password required"
|
|
55
60
|
},
|
|
56
61
|
]
|
|
57
62
|
|
|
@@ -74,25 +79,89 @@ SECRETS_SAMPLE = [
|
|
|
74
79
|
]
|
|
75
80
|
|
|
76
81
|
|
|
82
|
+
def _resolve_name_to_id(name: str, lookup: dict, resource_type: str) -> Optional[int]:
|
|
83
|
+
"""Resolve a name to ID using case-insensitive partial matching."""
|
|
84
|
+
if not name:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
# Try exact match first (case-insensitive)
|
|
88
|
+
name_lower = name.lower().strip()
|
|
89
|
+
for key, value in lookup.items():
|
|
90
|
+
if key.lower() == name_lower:
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
# Try partial match (name contains search term)
|
|
94
|
+
for key, value in lookup.items():
|
|
95
|
+
if name_lower in key.lower():
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_folder_path_map(safes: list, folders: list) -> dict[str, str]:
|
|
102
|
+
"""Build a map of folder paths to folder IDs.
|
|
103
|
+
|
|
104
|
+
Safes are top-level containers, folders can be nested.
|
|
105
|
+
Returns a dict like {"SafeName/FolderName": "folder-guid", ...}
|
|
106
|
+
"""
|
|
107
|
+
# Build ID -> object lookups
|
|
108
|
+
safe_by_id = {s.get("Id"): s for s in safes}
|
|
109
|
+
folder_by_id = {f.get("Id"): f for f in folders}
|
|
110
|
+
|
|
111
|
+
# Build path for each folder
|
|
112
|
+
path_map: dict[str, str] = {}
|
|
113
|
+
|
|
114
|
+
def get_path(folder_id: str) -> str:
|
|
115
|
+
"""Recursively build path for a folder."""
|
|
116
|
+
if folder_id in safe_by_id:
|
|
117
|
+
# It's a safe (top-level)
|
|
118
|
+
return safe_by_id[folder_id].get("Name", "")
|
|
119
|
+
|
|
120
|
+
folder = folder_by_id.get(folder_id)
|
|
121
|
+
if not folder:
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
parent_id = folder.get("ParentId")
|
|
125
|
+
folder_name = folder.get("Name", "")
|
|
126
|
+
|
|
127
|
+
if parent_id:
|
|
128
|
+
parent_path = get_path(parent_id)
|
|
129
|
+
if parent_path:
|
|
130
|
+
return f"{parent_path}/{folder_name}"
|
|
131
|
+
|
|
132
|
+
return folder_name
|
|
133
|
+
|
|
134
|
+
# Map all folders
|
|
135
|
+
for folder in folders:
|
|
136
|
+
folder_id = folder.get("Id")
|
|
137
|
+
path = get_path(folder_id)
|
|
138
|
+
if path:
|
|
139
|
+
path_map[path.lower()] = folder_id
|
|
140
|
+
|
|
141
|
+
return path_map
|
|
142
|
+
|
|
143
|
+
|
|
77
144
|
@import_app.command("systems")
|
|
78
145
|
def import_systems(
|
|
79
146
|
file: str = typer.Option(..., "--file", "-f", help="CSV file path"),
|
|
80
147
|
dry_run: bool = typer.Option(False, "--dry-run", help="Validate without creating"),
|
|
81
|
-
|
|
148
|
+
workgroup_override: Optional[str] = typer.Option(None, "--workgroup", "-w", help="Override workgroup for all rows (name or ID)"),
|
|
82
149
|
) -> None:
|
|
83
150
|
"""Import managed systems and accounts from CSV.
|
|
84
151
|
|
|
85
152
|
Each row creates a system + account. Multiple accounts per system use
|
|
86
153
|
multiple rows with the same system name (system is created only once).
|
|
87
154
|
|
|
88
|
-
|
|
89
|
-
|
|
155
|
+
Uses NAMES for workgroup, platform, functional_account - resolved to IDs automatically.
|
|
156
|
+
|
|
157
|
+
Required columns: system_name, ip_address, workgroup, account_name
|
|
158
|
+
Optional: platform, port, functional_account, elevation_command,
|
|
90
159
|
auto_manage, account_password, account_description
|
|
91
160
|
|
|
92
161
|
Examples:
|
|
93
162
|
bt pws import systems --file systems.csv --dry-run
|
|
94
163
|
bt pws import systems --file systems.csv
|
|
95
|
-
bt pws import systems --file systems.csv --workgroup
|
|
164
|
+
bt pws import systems --file systems.csv --workgroup "Default"
|
|
96
165
|
"""
|
|
97
166
|
try:
|
|
98
167
|
rows = read_csv(file)
|
|
@@ -102,73 +171,148 @@ def import_systems(
|
|
|
102
171
|
|
|
103
172
|
console.print(f"[dim]Read {len(rows)} rows from {file}[/dim]")
|
|
104
173
|
|
|
105
|
-
#
|
|
106
|
-
errors = []
|
|
107
|
-
required = ["name", "ip_address", "account_name"]
|
|
108
|
-
if not workgroup:
|
|
109
|
-
required.append("workgroup_id")
|
|
110
|
-
|
|
111
|
-
for i, row in enumerate(rows, 1):
|
|
112
|
-
row_errors = validate_required_fields(row, required, i)
|
|
113
|
-
errors.extend(row_errors)
|
|
114
|
-
|
|
115
|
-
if errors:
|
|
116
|
-
print_error("Validation errors:")
|
|
117
|
-
for err in errors[:20]:
|
|
118
|
-
console.print(f" [red]{err}[/red]")
|
|
119
|
-
if len(errors) > 20:
|
|
120
|
-
console.print(f" [red]... and {len(errors) - 20} more errors[/red]")
|
|
121
|
-
raise typer.Exit(1)
|
|
122
|
-
|
|
123
|
-
# Group rows by system name
|
|
124
|
-
systems_rows: dict[str, list[dict]] = {}
|
|
125
|
-
for row in rows:
|
|
126
|
-
name = row["name"].strip()
|
|
127
|
-
if name not in systems_rows:
|
|
128
|
-
systems_rows[name] = []
|
|
129
|
-
systems_rows[name].append(row)
|
|
130
|
-
|
|
131
|
-
console.print(f"[dim]Found {len(systems_rows)} unique systems with {len(rows)} total accounts[/dim]")
|
|
132
|
-
|
|
133
|
-
if dry_run:
|
|
134
|
-
console.print("\n[yellow]DRY RUN - No changes will be made[/yellow]\n")
|
|
135
|
-
table = Table(title="Systems to Create")
|
|
136
|
-
table.add_column("System", style="green")
|
|
137
|
-
table.add_column("IP", style="cyan")
|
|
138
|
-
table.add_column("Workgroup", style="yellow")
|
|
139
|
-
table.add_column("Platform", style="magenta")
|
|
140
|
-
table.add_column("Accounts", style="blue")
|
|
141
|
-
|
|
142
|
-
for name, sys_rows in systems_rows.items():
|
|
143
|
-
first = sys_rows[0]
|
|
144
|
-
wg = workgroup or parse_int(first.get("workgroup_id", ""))
|
|
145
|
-
accounts = ", ".join(r["account_name"] for r in sys_rows)
|
|
146
|
-
table.add_row(
|
|
147
|
-
name,
|
|
148
|
-
first.get("ip_address", ""),
|
|
149
|
-
str(wg),
|
|
150
|
-
first.get("platform_id", "2"),
|
|
151
|
-
accounts
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
console.print(table)
|
|
155
|
-
console.print(f"\n[dim]Would create {len(systems_rows)} systems with {len(rows)} accounts[/dim]")
|
|
156
|
-
return
|
|
157
|
-
|
|
158
|
-
# Actually import
|
|
174
|
+
# Connect and build lookup tables for name resolution
|
|
159
175
|
with get_client() as client:
|
|
160
176
|
client.authenticate()
|
|
161
177
|
|
|
178
|
+
console.print("[dim]Loading workgroups, platforms, functional accounts...[/dim]")
|
|
179
|
+
|
|
180
|
+
# Build name -> ID lookup tables
|
|
181
|
+
workgroups = client.list_workgroups()
|
|
182
|
+
workgroup_lookup = {w.get("Name", ""): w.get("ID") for w in workgroups}
|
|
183
|
+
|
|
184
|
+
platforms = client.list_platforms()
|
|
185
|
+
platform_lookup = {p.get("Name", ""): p.get("PlatformID") for p in platforms}
|
|
186
|
+
|
|
187
|
+
func_accounts = client.list_functional_accounts()
|
|
188
|
+
# Use DisplayName as the primary lookup key, also add AccountName as fallback
|
|
189
|
+
func_acct_lookup = {}
|
|
190
|
+
for f in func_accounts:
|
|
191
|
+
fa_id = f.get("FunctionalAccountID")
|
|
192
|
+
# Primary: DisplayName (e.g., "Linux - Local")
|
|
193
|
+
if f.get("DisplayName"):
|
|
194
|
+
func_acct_lookup[f["DisplayName"]] = fa_id
|
|
195
|
+
# Fallback: AccountName (e.g., "svc_passwordsafe")
|
|
196
|
+
if f.get("AccountName"):
|
|
197
|
+
func_acct_lookup[f["AccountName"]] = fa_id
|
|
198
|
+
|
|
199
|
+
# Resolve workgroup override
|
|
200
|
+
wg_override_id = None
|
|
201
|
+
if workgroup_override:
|
|
202
|
+
# Try as ID first
|
|
203
|
+
try:
|
|
204
|
+
wg_override_id = int(workgroup_override)
|
|
205
|
+
except ValueError:
|
|
206
|
+
wg_override_id = _resolve_name_to_id(workgroup_override, workgroup_lookup, "workgroup")
|
|
207
|
+
if not wg_override_id:
|
|
208
|
+
print_error(f"Workgroup not found: {workgroup_override}")
|
|
209
|
+
console.print("[dim]Available workgroups:[/dim]")
|
|
210
|
+
for name in workgroup_lookup.keys():
|
|
211
|
+
console.print(f" - {name}")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
|
|
214
|
+
# Validate all rows first
|
|
215
|
+
errors = []
|
|
216
|
+
# Support both old column names (backward compat) and new names
|
|
217
|
+
name_col = "system_name" if "system_name" in rows[0] else "name"
|
|
218
|
+
wg_col = "workgroup" if "workgroup" in rows[0] else "workgroup_id"
|
|
219
|
+
|
|
220
|
+
required = [name_col, "ip_address", "account_name"]
|
|
221
|
+
if not wg_override_id:
|
|
222
|
+
required.append(wg_col)
|
|
223
|
+
|
|
224
|
+
for i, row in enumerate(rows, 1):
|
|
225
|
+
row_errors = validate_required_fields(row, required, i)
|
|
226
|
+
errors.extend(row_errors)
|
|
227
|
+
|
|
228
|
+
if errors:
|
|
229
|
+
print_error("Validation errors:")
|
|
230
|
+
for err in errors[:20]:
|
|
231
|
+
console.print(f" [red]{err}[/red]")
|
|
232
|
+
if len(errors) > 20:
|
|
233
|
+
console.print(f" [red]... and {len(errors) - 20} more errors[/red]")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
# Group rows by system name
|
|
237
|
+
systems_rows: dict[str, list[dict]] = {}
|
|
238
|
+
for row in rows:
|
|
239
|
+
name = row.get(name_col, "").strip()
|
|
240
|
+
if name not in systems_rows:
|
|
241
|
+
systems_rows[name] = []
|
|
242
|
+
systems_rows[name].append(row)
|
|
243
|
+
|
|
244
|
+
console.print(f"[dim]Found {len(systems_rows)} unique systems with {len(rows)} total accounts[/dim]")
|
|
245
|
+
|
|
246
|
+
if dry_run:
|
|
247
|
+
console.print("\n[yellow]DRY RUN - No changes will be made[/yellow]\n")
|
|
248
|
+
table = Table(title="Systems to Create")
|
|
249
|
+
table.add_column("System", style="green")
|
|
250
|
+
table.add_column("IP", style="cyan")
|
|
251
|
+
table.add_column("Workgroup", style="yellow")
|
|
252
|
+
table.add_column("Platform", style="magenta")
|
|
253
|
+
table.add_column("Accounts", style="blue")
|
|
254
|
+
|
|
255
|
+
for name, sys_rows in systems_rows.items():
|
|
256
|
+
first = sys_rows[0]
|
|
257
|
+
wg_name = first.get(wg_col, "")
|
|
258
|
+
plat_name = first.get("platform", first.get("platform_id", "Linux"))
|
|
259
|
+
accounts = ", ".join(r["account_name"] for r in sys_rows)
|
|
260
|
+
table.add_row(name, first.get("ip_address", ""), wg_name, plat_name, accounts)
|
|
261
|
+
|
|
262
|
+
console.print(table)
|
|
263
|
+
console.print(f"\n[dim]Would create {len(systems_rows)} systems with {len(rows)} accounts[/dim]")
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
# Actually import
|
|
162
267
|
created_systems = 0
|
|
163
268
|
created_accounts = 0
|
|
164
|
-
system_ids: dict[str, int] = {}
|
|
269
|
+
system_ids: dict[str, int] = {}
|
|
165
270
|
|
|
166
271
|
for name, sys_rows in systems_rows.items():
|
|
167
272
|
first = sys_rows[0]
|
|
168
|
-
|
|
273
|
+
|
|
274
|
+
# Resolve workgroup
|
|
275
|
+
if wg_override_id:
|
|
276
|
+
wg_id = wg_override_id
|
|
277
|
+
else:
|
|
278
|
+
wg_val = first.get(wg_col, "").strip()
|
|
279
|
+
try:
|
|
280
|
+
wg_id = int(wg_val)
|
|
281
|
+
except ValueError:
|
|
282
|
+
wg_id = _resolve_name_to_id(wg_val, workgroup_lookup, "workgroup")
|
|
169
283
|
|
|
170
284
|
if not wg_id:
|
|
171
|
-
print_warning(f"Skipping {name}:
|
|
285
|
+
print_warning(f"Skipping {name}: Could not resolve workgroup '{first.get(wg_col, '')}'")
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Resolve platform
|
|
289
|
+
plat_val = first.get("platform", first.get("platform_id", "")).strip()
|
|
290
|
+
try:
|
|
291
|
+
platform_id = int(plat_val) if plat_val else 2
|
|
292
|
+
except ValueError:
|
|
293
|
+
platform_id = _resolve_name_to_id(plat_val, platform_lookup, "platform") or 2
|
|
294
|
+
|
|
295
|
+
# Resolve functional account
|
|
296
|
+
func_val = first.get("functional_account", first.get("functional_account_id", "")).strip()
|
|
297
|
+
func_acct_id = None
|
|
298
|
+
if func_val:
|
|
299
|
+
try:
|
|
300
|
+
func_acct_id = int(func_val)
|
|
301
|
+
except ValueError:
|
|
302
|
+
func_acct_id = _resolve_name_to_id(func_val, func_acct_lookup, "functional account")
|
|
303
|
+
|
|
304
|
+
# Get auto_manage flag early for validation
|
|
305
|
+
auto_manage = parse_bool(first.get("auto_manage", ""))
|
|
306
|
+
|
|
307
|
+
# Validate auto_manage + functional account combination
|
|
308
|
+
if auto_manage and func_val and not func_acct_id:
|
|
309
|
+
print_warning(f"Skipping {name}: auto_manage=true but functional account '{func_val}' not found")
|
|
310
|
+
console.print("[dim]Available functional accounts:[/dim]")
|
|
311
|
+
for fa_name in func_acct_lookup.keys():
|
|
312
|
+
console.print(f" - {fa_name}")
|
|
313
|
+
continue
|
|
314
|
+
if auto_manage and not func_val:
|
|
315
|
+
print_warning(f"Skipping {name}: auto_manage=true requires a functional_account")
|
|
172
316
|
continue
|
|
173
317
|
|
|
174
318
|
try:
|
|
@@ -183,10 +327,7 @@ def import_systems(
|
|
|
183
327
|
|
|
184
328
|
# Create managed system
|
|
185
329
|
console.print(f"[dim]Creating managed system '{name}'...[/dim]")
|
|
186
|
-
platform_id = parse_int(first.get("platform_id", ""), 2)
|
|
187
330
|
port = parse_int(first.get("port", ""), 22)
|
|
188
|
-
func_acct = parse_int(first.get("functional_account_id", ""))
|
|
189
|
-
auto_manage = parse_bool(first.get("auto_manage", ""))
|
|
190
331
|
elevation = first.get("elevation_command", "").strip() or None
|
|
191
332
|
|
|
192
333
|
system = client.create_managed_system(
|
|
@@ -194,8 +335,8 @@ def import_systems(
|
|
|
194
335
|
platform_id=platform_id,
|
|
195
336
|
asset_id=asset_id,
|
|
196
337
|
port=port,
|
|
197
|
-
functional_account_id=
|
|
198
|
-
auto_management_flag=auto_manage if
|
|
338
|
+
functional_account_id=func_acct_id,
|
|
339
|
+
auto_management_flag=auto_manage if func_acct_id else False,
|
|
199
340
|
elevation_command=elevation,
|
|
200
341
|
)
|
|
201
342
|
system_id = system.get("ManagedSystemID")
|
|
@@ -214,7 +355,7 @@ def import_systems(
|
|
|
214
355
|
system_id=system_id,
|
|
215
356
|
account_name=account_name,
|
|
216
357
|
password=password,
|
|
217
|
-
auto_management_flag=auto_manage if
|
|
358
|
+
auto_management_flag=auto_manage if func_acct_id else False,
|
|
218
359
|
)
|
|
219
360
|
account_id = account.get("ManagedAccountID")
|
|
220
361
|
created_accounts += 1
|
|
@@ -301,13 +442,11 @@ def import_secrets(
|
|
|
301
442
|
with get_client() as client:
|
|
302
443
|
client.authenticate()
|
|
303
444
|
|
|
304
|
-
#
|
|
445
|
+
# Build folder path -> ID map from safes and folders
|
|
446
|
+
console.print("[dim]Loading safes and folders...[/dim]")
|
|
447
|
+
safes = client.list_safes()
|
|
305
448
|
all_folders = client.list_folders()
|
|
306
|
-
folder_map
|
|
307
|
-
for f in all_folders:
|
|
308
|
-
path = f.get("FolderPath", "") or f.get("Name", "")
|
|
309
|
-
if path:
|
|
310
|
-
folder_map[path.lower()] = f.get("Id")
|
|
449
|
+
folder_map = _build_folder_path_map(safes, all_folders)
|
|
311
450
|
|
|
312
451
|
created = 0
|
|
313
452
|
for row in rows:
|
|
@@ -318,6 +457,11 @@ def import_secrets(
|
|
|
318
457
|
folder_id = folder_map.get(folder_path.lower())
|
|
319
458
|
if not folder_id:
|
|
320
459
|
print_warning(f"Folder not found: {folder_path}, skipping {title}")
|
|
460
|
+
console.print("[dim]Available folder paths:[/dim]")
|
|
461
|
+
for path in sorted(folder_map.keys())[:10]:
|
|
462
|
+
console.print(f" - {path}")
|
|
463
|
+
if len(folder_map) > 10:
|
|
464
|
+
console.print(f" ... and {len(folder_map) - 10} more")
|
|
321
465
|
continue
|
|
322
466
|
|
|
323
467
|
try:
|
|
@@ -355,22 +499,46 @@ def export_systems(
|
|
|
355
499
|
file: str = typer.Option("pws-systems-template.csv", "--file", "-f", help="Output file path"),
|
|
356
500
|
sample: bool = typer.Option(True, "--sample/--no-sample", help="Export sample template (default) or current data"),
|
|
357
501
|
) -> None:
|
|
358
|
-
"""Export
|
|
502
|
+
"""Export systems CSV template or current data.
|
|
503
|
+
|
|
504
|
+
Uses human-readable NAMES for workgroup, platform, functional_account.
|
|
359
505
|
|
|
360
506
|
Examples:
|
|
361
507
|
bt pws export systems --file systems-template.csv
|
|
362
|
-
bt pws export systems --file systems
|
|
508
|
+
bt pws export systems --file current-systems.csv --no-sample
|
|
363
509
|
"""
|
|
364
510
|
try:
|
|
365
511
|
if sample:
|
|
366
512
|
write_csv(file, SYSTEMS_SAMPLE, SYSTEMS_COLUMNS)
|
|
367
513
|
print_success(f"Sample systems template exported to: {file}")
|
|
368
514
|
console.print(f"[dim]Contains {len(SYSTEMS_SAMPLE)} example rows[/dim]")
|
|
515
|
+
console.print("\n[dim]Column descriptions:[/dim]")
|
|
516
|
+
console.print(" system_name - Unique name for the managed system")
|
|
517
|
+
console.print(" ip_address - IP or hostname")
|
|
518
|
+
console.print(" workgroup - Workgroup NAME (run: bt pws workgroups list)")
|
|
519
|
+
console.print(" platform - Platform NAME, partial match OK (run: bt pws platforms list)")
|
|
520
|
+
console.print(" port - Connection port (22 for SSH, 5985 for WinRM)")
|
|
521
|
+
console.print(" functional_account - Functional account NAME for auto-management (bt pws functional list)")
|
|
522
|
+
console.print(" elevation_command - 'sudo' or other elevation command (optional)")
|
|
523
|
+
console.print(" auto_manage - true=PWS rotates password, false=manual management")
|
|
524
|
+
console.print(" account_name - Account username to manage")
|
|
525
|
+
console.print(" account_password - Required if auto_manage=false, empty if auto_manage=true")
|
|
526
|
+
console.print(" account_description- Description for the account")
|
|
369
527
|
else:
|
|
370
|
-
# Export actual data from API
|
|
528
|
+
# Export actual data from API with names resolved
|
|
371
529
|
with get_client() as client:
|
|
372
530
|
client.authenticate()
|
|
373
531
|
|
|
532
|
+
# Build ID -> name lookup tables
|
|
533
|
+
workgroups = client.list_workgroups()
|
|
534
|
+
wg_names = {w.get("ID"): w.get("Name", "") for w in workgroups}
|
|
535
|
+
|
|
536
|
+
platforms = client.list_platforms()
|
|
537
|
+
plat_names = {p.get("PlatformID"): p.get("Name", "") for p in platforms}
|
|
538
|
+
|
|
539
|
+
func_accounts = client.list_functional_accounts()
|
|
540
|
+
func_names = {f.get("FunctionalAccountID"): f.get("FunctionalAccountName", "") for f in func_accounts}
|
|
541
|
+
|
|
374
542
|
systems = client.list_managed_systems()
|
|
375
543
|
rows = []
|
|
376
544
|
|
|
@@ -378,14 +546,18 @@ def export_systems(
|
|
|
378
546
|
system_id = sys.get("ManagedSystemID")
|
|
379
547
|
accounts = client.list_managed_accounts(system_id=system_id)
|
|
380
548
|
|
|
549
|
+
wg_id = sys.get("WorkgroupID")
|
|
550
|
+
plat_id = sys.get("PlatformID")
|
|
551
|
+
func_id = sys.get("FunctionalAccountID")
|
|
552
|
+
|
|
381
553
|
for acc in accounts:
|
|
382
554
|
rows.append({
|
|
383
|
-
"
|
|
555
|
+
"system_name": sys.get("SystemName", ""),
|
|
384
556
|
"ip_address": sys.get("IPAddress", ""),
|
|
385
|
-
"
|
|
386
|
-
"
|
|
557
|
+
"workgroup": wg_names.get(wg_id, str(wg_id or "")),
|
|
558
|
+
"platform": plat_names.get(plat_id, str(plat_id or "")),
|
|
387
559
|
"port": str(sys.get("Port", "")),
|
|
388
|
-
"
|
|
560
|
+
"functional_account": func_names.get(func_id, "") if func_id else "",
|
|
389
561
|
"elevation_command": sys.get("ElevationCommand", "") or "",
|
|
390
562
|
"auto_manage": "true" if sys.get("AutoManagementFlag") else "false",
|
|
391
563
|
"account_name": acc.get("AccountName", ""),
|
|
@@ -419,22 +591,25 @@ def export_secrets(
|
|
|
419
591
|
write_csv(file, SECRETS_SAMPLE, SECRETS_COLUMNS)
|
|
420
592
|
print_success(f"Sample secrets template exported to: {file}")
|
|
421
593
|
console.print(f"[dim]Contains {len(SECRETS_SAMPLE)} example rows[/dim]")
|
|
594
|
+
console.print("\n[dim]Column descriptions:[/dim]")
|
|
595
|
+
console.print(" folder_path - Full path: SafeName/FolderName (run: bt pws secrets folders list)")
|
|
596
|
+
console.print(" title - Unique title for the secret")
|
|
597
|
+
console.print(" username - Username/identifier (optional)")
|
|
598
|
+
console.print(" password - Password/secret value")
|
|
599
|
+
console.print(" description - Description of the secret")
|
|
600
|
+
console.print(" notes - Additional notes (can be JSON)")
|
|
422
601
|
else:
|
|
423
602
|
# Export actual data from API
|
|
424
603
|
with get_client() as client:
|
|
425
604
|
client.authenticate()
|
|
426
605
|
|
|
427
606
|
secrets = client.list_secrets()
|
|
428
|
-
folders = client.list_folders()
|
|
429
|
-
|
|
430
|
-
# Map folder IDs to paths
|
|
431
|
-
folder_paths = {f.get("Id"): f.get("FolderPath", "") or f.get("Name", "") for f in folders}
|
|
432
607
|
|
|
433
608
|
rows = []
|
|
434
609
|
for s in secrets:
|
|
435
|
-
|
|
610
|
+
# Secrets have FolderPath directly (e.g., "SafeName/FolderName")
|
|
436
611
|
rows.append({
|
|
437
|
-
"folder_path":
|
|
612
|
+
"folder_path": s.get("FolderPath", "") or s.get("Folder", ""),
|
|
438
613
|
"title": s.get("Title", ""),
|
|
439
614
|
"username": s.get("Username", "") or "",
|
|
440
615
|
"password": "", # Don't export passwords
|
bt_cli/pws/commands/secrets.py
CHANGED
|
@@ -568,7 +568,7 @@ def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
|
|
|
568
568
|
table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
|
|
569
569
|
table.add_column("Title", style="green")
|
|
570
570
|
table.add_column("Username", style="yellow")
|
|
571
|
-
table.add_column("Folder", style="blue")
|
|
571
|
+
table.add_column("Folder Path", style="blue")
|
|
572
572
|
table.add_column("Modified", style="dim")
|
|
573
573
|
|
|
574
574
|
for secret in secrets:
|
|
@@ -576,7 +576,7 @@ def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
|
|
|
576
576
|
secret.get("Id", "")[:36],
|
|
577
577
|
secret.get("Title", ""),
|
|
578
578
|
secret.get("Username", "-"),
|
|
579
|
-
secret.get("
|
|
579
|
+
secret.get("FolderPath", secret.get("Folder", "-")),
|
|
580
580
|
str(secret.get("ModifiedOn", "-"))[:19],
|
|
581
581
|
)
|
|
582
582
|
|
|
@@ -653,7 +653,7 @@ def get_secret(
|
|
|
653
653
|
console.print(f" Password: {secret.get('Password', '-')}")
|
|
654
654
|
else:
|
|
655
655
|
console.print(" Password: ********")
|
|
656
|
-
console.print(f" Folder: {secret.get('
|
|
656
|
+
console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
|
|
657
657
|
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
658
658
|
if secret.get("Notes"):
|
|
659
659
|
console.print(f" Notes: {secret.get('Notes')}")
|
|
@@ -961,7 +961,7 @@ def get_text_secret(
|
|
|
961
961
|
else:
|
|
962
962
|
console.print(f"\n[bold cyan]Text Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
|
|
963
963
|
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
964
|
-
console.print(f" Folder: {secret.get('
|
|
964
|
+
console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
|
|
965
965
|
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
966
966
|
if show_text:
|
|
967
967
|
console.print(f" Text Content:\n{secret.get('Password', '-')}")
|
|
@@ -1065,7 +1065,7 @@ def get_file_secret(
|
|
|
1065
1065
|
console.print(f"\n[bold cyan]File Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
|
|
1066
1066
|
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
1067
1067
|
console.print(f" FileName: {secret.get('FileName', '-')}")
|
|
1068
|
-
console.print(f" Folder: {secret.get('
|
|
1068
|
+
console.print(f" Folder Path: {secret.get('FolderPath', secret.get('Folder', '-'))}")
|
|
1069
1069
|
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
1070
1070
|
console.print(f" Created: {secret.get('CreatedOn', '-')}")
|
|
1071
1071
|
console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
|
|
@@ -8,7 +8,7 @@ bt_cli/core/__init__.py,sha256=Yv_0gTKQcbEYNbNQ07YiK4sC8ueHktI-D20YlPWlT7g,49
|
|
|
8
8
|
bt_cli/core/auth.py,sha256=-vzGCgk2ZOsHExZBcxMHer68K3Bi0RMWF-17wtIZvsg,6723
|
|
9
9
|
bt_cli/core/client.py,sha256=pGxk5vI-U5_csiwBvMr_YwloOTmmWuFhX9TAA9ynoZw,8808
|
|
10
10
|
bt_cli/core/config.py,sha256=lQZgNH858HCIuizyDDFU0Bs1VbHjy3vaA2OthVgc7-w,13485
|
|
11
|
-
bt_cli/core/config_file.py,sha256=
|
|
11
|
+
bt_cli/core/config_file.py,sha256=k7yH-M53fEt9ahQBABWCcd9CBdfwjwGK20chNndF_UQ,13740
|
|
12
12
|
bt_cli/core/csv_utils.py,sha256=L7i-YGSeQV9RnzanKSQM1yvXgkmaQCpBoxUk9fV6RLg,2412
|
|
13
13
|
bt_cli/core/errors.py,sha256=-LWipqdn8w-FMAC1W1jFbTMAlooiJqmyuR5j1VWMPyM,8783
|
|
14
14
|
bt_cli/core/output.py,sha256=ACazDf3XAfJhAlns5fh6bQXkRIIGlBFuq1z2M-IBrwQ,5156
|
|
@@ -55,7 +55,7 @@ bt_cli/epmw/commands/auth.py,sha256=2oC3h01_eh4XyEvsar_ffJAqGKXGE_PrhHQJWFswlN8,
|
|
|
55
55
|
bt_cli/epmw/commands/computers.py,sha256=K0eBd8HOaVCe8hhHPlUuqCrZduhPuvwlkxdYt6YjJxM,4146
|
|
56
56
|
bt_cli/epmw/commands/events.py,sha256=not-9_poxQWRBB4sqZSzJo6031A3IZTdg6uAl2Agk2g,7707
|
|
57
57
|
bt_cli/epmw/commands/groups.py,sha256=PaMcjqfr2b3xS3W3V2A9CFTlMOAs38UmJbi5LKXbbg0,6759
|
|
58
|
-
bt_cli/epmw/commands/policies.py,sha256=
|
|
58
|
+
bt_cli/epmw/commands/policies.py,sha256=0jFQ-ZLzTAC6rPbMWL3DfWm0jdgvE2iwR_dHUHI-QG4,24600
|
|
59
59
|
bt_cli/epmw/commands/quick.py,sha256=X2iTwnDgAvatW8wVY4nqQztLmIEoGmbn1YeCVwHyvzY,13876
|
|
60
60
|
bt_cli/epmw/commands/requests.py,sha256=qcx6MsPXVm2UkaBIkhpf3RZh6Lo-BBz2OPF4Tl3h4p8,8251
|
|
61
61
|
bt_cli/epmw/commands/roles.py,sha256=WYAelLUwzJkBB7rlKoR4FqSxfdoDjV310KcNl6ixCCM,2396
|
|
@@ -102,11 +102,11 @@ bt_cli/pws/commands/credentials.py,sha256=p1efDBCI35exGS-6iHQ5IHBQIo0TJMfWuCBoek
|
|
|
102
102
|
bt_cli/pws/commands/databases.py,sha256=hHGg7hNtE5mwClCpWoSmVSB1-LdY3PORu9iMRuhbxOg,11961
|
|
103
103
|
bt_cli/pws/commands/directories.py,sha256=NBZfivK-gh3BlC7nXyM_iWeE-qjPIPMlsptkNGtsQJk,7985
|
|
104
104
|
bt_cli/pws/commands/functional.py,sha256=C9iMSjNfG2VF-kDAM8a3rlMwp_wMePZpGRPNWwkSTBI,12705
|
|
105
|
-
bt_cli/pws/commands/import_export.py,sha256=
|
|
105
|
+
bt_cli/pws/commands/import_export.py,sha256=jGrGqInxmWAGWVdUxVBt1hHtu_CqmXO65oV5RoUsaAA,27395
|
|
106
106
|
bt_cli/pws/commands/platforms.py,sha256=1NZM8BFMG8-AL3AzRZjU0X8zZwt584Z4FOxQf6mGemc,3999
|
|
107
107
|
bt_cli/pws/commands/quick.py,sha256=3i985r7A8_0zhXTDNxG9NLbGHXlXAAxZL2tMyAAIUyg,70769
|
|
108
108
|
bt_cli/pws/commands/search.py,sha256=6AHJgk30mLneviODOYX4xL9VwPZo1bDUoix3oC99LMM,9875
|
|
109
|
-
bt_cli/pws/commands/secrets.py,sha256
|
|
109
|
+
bt_cli/pws/commands/secrets.py,sha256=-8WInmkRxhq2kESDyMwvtx3hS7OvB-60-puj_2GnH_4,51664
|
|
110
110
|
bt_cli/pws/commands/systems.py,sha256=UFWNgyfI--NjKXqSWVbw_g_Ar_td4-Roa0tK1AJSuQE,16905
|
|
111
111
|
bt_cli/pws/commands/users.py,sha256=yMMC1I32QOjsMr2taU4qD7WajdJ7W3mNDQITbqKCEP4,14078
|
|
112
112
|
bt_cli/pws/commands/workgroups.py,sha256=fvjGniHE6T_oktQSvGhjFXeeDxeubkJXTwR4RNtrA4E,6080
|
|
@@ -115,7 +115,7 @@ bt_cli/pws/models/account.py,sha256=OSCMyULPOH1Yu2WOzK0ZQhSRrggGpb2JPHScwGLqUgI,
|
|
|
115
115
|
bt_cli/pws/models/asset.py,sha256=Fl0AlR4_9Yyyu36FL1eKF29DNsxsB-r7FaOBRlfOg2Q,4081
|
|
116
116
|
bt_cli/pws/models/common.py,sha256=D9Ah4ob5CIiFhTt_IR9nF2cBWRHS2z9OyBR2Sss5yzw,3487
|
|
117
117
|
bt_cli/pws/models/system.py,sha256=D_J0x1A92H2n6BsaBEK9PSAAcs3BTifA5-M9SQqQFGA,5856
|
|
118
|
-
bt_cli-0.4.
|
|
119
|
-
bt_cli-0.4.
|
|
120
|
-
bt_cli-0.4.
|
|
121
|
-
bt_cli-0.4.
|
|
118
|
+
bt_cli-0.4.15.dist-info/METADATA,sha256=Y4PqMIcnmFA7NIVq4jwHvtiCz8olnpps9HaHoipijeQ,11803
|
|
119
|
+
bt_cli-0.4.15.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
120
|
+
bt_cli-0.4.15.dist-info/entry_points.txt,sha256=NCOEqTI-XKpJOux0JKKhbRElz0B7upayh_d99X5hoLs,38
|
|
121
|
+
bt_cli-0.4.15.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|