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.
- bt_cli/__init__.py +3 -0
- bt_cli/cli.py +830 -0
- bt_cli/commands/__init__.py +1 -0
- bt_cli/commands/configure.py +415 -0
- bt_cli/commands/learn.py +229 -0
- bt_cli/commands/quick.py +784 -0
- bt_cli/core/__init__.py +1 -0
- bt_cli/core/auth.py +213 -0
- bt_cli/core/client.py +313 -0
- bt_cli/core/config.py +393 -0
- bt_cli/core/config_file.py +420 -0
- bt_cli/core/csv_utils.py +91 -0
- bt_cli/core/errors.py +247 -0
- bt_cli/core/output.py +205 -0
- bt_cli/core/prompts.py +87 -0
- bt_cli/core/rest_debug.py +221 -0
- bt_cli/data/CLAUDE.md +94 -0
- bt_cli/data/__init__.py +0 -0
- bt_cli/data/skills/bt/SKILL.md +108 -0
- bt_cli/data/skills/entitle/SKILL.md +170 -0
- bt_cli/data/skills/epmw/SKILL.md +144 -0
- bt_cli/data/skills/pra/SKILL.md +150 -0
- bt_cli/data/skills/pws/SKILL.md +198 -0
- bt_cli/entitle/__init__.py +1 -0
- bt_cli/entitle/client/__init__.py +5 -0
- bt_cli/entitle/client/base.py +443 -0
- bt_cli/entitle/commands/__init__.py +24 -0
- bt_cli/entitle/commands/accounts.py +53 -0
- bt_cli/entitle/commands/applications.py +39 -0
- bt_cli/entitle/commands/auth.py +68 -0
- bt_cli/entitle/commands/bundles.py +218 -0
- bt_cli/entitle/commands/integrations.py +60 -0
- bt_cli/entitle/commands/permissions.py +70 -0
- bt_cli/entitle/commands/policies.py +97 -0
- bt_cli/entitle/commands/resources.py +131 -0
- bt_cli/entitle/commands/roles.py +74 -0
- bt_cli/entitle/commands/users.py +123 -0
- bt_cli/entitle/commands/workflows.py +187 -0
- bt_cli/entitle/models/__init__.py +31 -0
- bt_cli/entitle/models/bundle.py +28 -0
- bt_cli/entitle/models/common.py +37 -0
- bt_cli/entitle/models/integration.py +30 -0
- bt_cli/entitle/models/permission.py +27 -0
- bt_cli/entitle/models/policy.py +25 -0
- bt_cli/entitle/models/resource.py +29 -0
- bt_cli/entitle/models/role.py +28 -0
- bt_cli/entitle/models/user.py +24 -0
- bt_cli/entitle/models/workflow.py +55 -0
- bt_cli/epmw/__init__.py +1 -0
- bt_cli/epmw/client/__init__.py +5 -0
- bt_cli/epmw/client/base.py +848 -0
- bt_cli/epmw/commands/__init__.py +33 -0
- bt_cli/epmw/commands/audits.py +250 -0
- bt_cli/epmw/commands/auth.py +55 -0
- bt_cli/epmw/commands/computers.py +140 -0
- bt_cli/epmw/commands/events.py +233 -0
- bt_cli/epmw/commands/groups.py +215 -0
- bt_cli/epmw/commands/policies.py +673 -0
- bt_cli/epmw/commands/quick.py +348 -0
- bt_cli/epmw/commands/requests.py +224 -0
- bt_cli/epmw/commands/roles.py +78 -0
- bt_cli/epmw/commands/tasks.py +38 -0
- bt_cli/epmw/commands/users.py +219 -0
- bt_cli/epmw/models/__init__.py +1 -0
- bt_cli/pra/__init__.py +1 -0
- bt_cli/pra/client/__init__.py +5 -0
- bt_cli/pra/client/base.py +618 -0
- bt_cli/pra/commands/__init__.py +30 -0
- bt_cli/pra/commands/auth.py +55 -0
- bt_cli/pra/commands/import_export.py +442 -0
- bt_cli/pra/commands/jump_clients.py +139 -0
- bt_cli/pra/commands/jump_groups.py +146 -0
- bt_cli/pra/commands/jump_items.py +638 -0
- bt_cli/pra/commands/jumpoints.py +95 -0
- bt_cli/pra/commands/policies.py +197 -0
- bt_cli/pra/commands/quick.py +470 -0
- bt_cli/pra/commands/teams.py +81 -0
- bt_cli/pra/commands/users.py +87 -0
- bt_cli/pra/commands/vault.py +564 -0
- bt_cli/pra/models/__init__.py +27 -0
- bt_cli/pra/models/common.py +12 -0
- bt_cli/pra/models/jump_client.py +25 -0
- bt_cli/pra/models/jump_group.py +15 -0
- bt_cli/pra/models/jump_item.py +72 -0
- bt_cli/pra/models/jumpoint.py +19 -0
- bt_cli/pra/models/team.py +14 -0
- bt_cli/pra/models/user.py +17 -0
- bt_cli/pra/models/vault.py +45 -0
- bt_cli/pws/__init__.py +1 -0
- bt_cli/pws/client/__init__.py +5 -0
- bt_cli/pws/client/base.py +356 -0
- bt_cli/pws/client/beyondinsight.py +869 -0
- bt_cli/pws/client/passwordsafe.py +1786 -0
- bt_cli/pws/commands/__init__.py +33 -0
- bt_cli/pws/commands/accounts.py +372 -0
- bt_cli/pws/commands/assets.py +311 -0
- bt_cli/pws/commands/auth.py +166 -0
- bt_cli/pws/commands/clouds.py +221 -0
- bt_cli/pws/commands/config.py +344 -0
- bt_cli/pws/commands/credentials.py +347 -0
- bt_cli/pws/commands/databases.py +306 -0
- bt_cli/pws/commands/directories.py +199 -0
- bt_cli/pws/commands/functional.py +298 -0
- bt_cli/pws/commands/import_export.py +452 -0
- bt_cli/pws/commands/platforms.py +118 -0
- bt_cli/pws/commands/quick.py +1646 -0
- bt_cli/pws/commands/search.py +256 -0
- bt_cli/pws/commands/secrets.py +1343 -0
- bt_cli/pws/commands/systems.py +389 -0
- bt_cli/pws/commands/users.py +415 -0
- bt_cli/pws/commands/workgroups.py +166 -0
- bt_cli/pws/config.py +18 -0
- bt_cli/pws/models/__init__.py +19 -0
- bt_cli/pws/models/account.py +186 -0
- bt_cli/pws/models/asset.py +102 -0
- bt_cli/pws/models/common.py +132 -0
- bt_cli/pws/models/system.py +121 -0
- bt_cli-0.4.13.dist-info/METADATA +417 -0
- bt_cli-0.4.13.dist-info/RECORD +121 -0
- bt_cli-0.4.13.dist-info/WHEEL +4 -0
- bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
"""CLI commands for Secrets Safe management."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from ...core.output import print_api_error
|
|
12
|
+
from ..client.base import get_client
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe safes, folders, and secrets")
|
|
15
|
+
safes_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe safes (top-level containers)")
|
|
16
|
+
folders_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe folders")
|
|
17
|
+
secrets_app = typer.Typer(no_args_is_help=True, help="Manage Secrets Safe secrets")
|
|
18
|
+
|
|
19
|
+
app.add_typer(safes_app, name="safes")
|
|
20
|
+
app.add_typer(folders_app, name="folders")
|
|
21
|
+
app.add_typer(secrets_app, name="secrets")
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# Safes Commands
|
|
28
|
+
# =============================================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def print_safes_table(safes: list[dict], title: str = "Safes") -> None:
|
|
32
|
+
"""Print safes in a formatted table."""
|
|
33
|
+
table = Table(title=title)
|
|
34
|
+
table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
|
|
35
|
+
table.add_column("Name", style="green")
|
|
36
|
+
table.add_column("Description", style="dim")
|
|
37
|
+
|
|
38
|
+
for safe in safes:
|
|
39
|
+
table.add_row(
|
|
40
|
+
safe.get("Id", "")[:36],
|
|
41
|
+
safe.get("Name", ""),
|
|
42
|
+
safe.get("Description", "-") or "-",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
console.print(table)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@safes_app.command("list")
|
|
49
|
+
def list_safes(
|
|
50
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
|
|
51
|
+
fetch_all: bool = typer.Option(False, "--all", help="Fetch all results"),
|
|
52
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""List all Secrets Safe safes accessible to current user.
|
|
55
|
+
|
|
56
|
+
Examples:
|
|
57
|
+
bt pws secrets safes list # First 50 safes
|
|
58
|
+
bt pws secrets safes list --all # All safes
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
with get_client() as client:
|
|
62
|
+
client.authenticate()
|
|
63
|
+
safes = client.list_safes()
|
|
64
|
+
|
|
65
|
+
# Apply client-side limit
|
|
66
|
+
total_count = len(safes)
|
|
67
|
+
if not fetch_all and len(safes) > limit:
|
|
68
|
+
safes = safes[:limit]
|
|
69
|
+
|
|
70
|
+
if output == "json":
|
|
71
|
+
console.print_json(json.dumps(safes, default=str))
|
|
72
|
+
else:
|
|
73
|
+
if safes:
|
|
74
|
+
print_safes_table(safes)
|
|
75
|
+
if not fetch_all and total_count > limit:
|
|
76
|
+
console.print(f"[dim]Showing {len(safes)} of {total_count} results. Use --all to fetch all results.[/dim]")
|
|
77
|
+
else:
|
|
78
|
+
console.print("[yellow]No safes found.[/yellow]")
|
|
79
|
+
|
|
80
|
+
except httpx.HTTPStatusError as e:
|
|
81
|
+
print_api_error(e, "manage secrets")
|
|
82
|
+
raise typer.Exit(1)
|
|
83
|
+
except httpx.RequestError as e:
|
|
84
|
+
print_api_error(e, "manage secrets")
|
|
85
|
+
raise typer.Exit(1)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print_api_error(e, "manage secrets")
|
|
88
|
+
raise typer.Exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@safes_app.command("get")
|
|
92
|
+
def get_safe(
|
|
93
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
|
|
94
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Get a safe by ID."""
|
|
97
|
+
try:
|
|
98
|
+
with get_client() as client:
|
|
99
|
+
client.authenticate()
|
|
100
|
+
safe = client.get_safe(safe_id)
|
|
101
|
+
|
|
102
|
+
if output == "json":
|
|
103
|
+
console.print_json(json.dumps(safe, default=str))
|
|
104
|
+
else:
|
|
105
|
+
console.print(f"\n[bold cyan]Safe: {safe.get('Name', 'Unknown')}[/bold cyan]\n")
|
|
106
|
+
console.print(f" ID: {safe.get('Id', 'N/A')}")
|
|
107
|
+
console.print(f" Description: {safe.get('Description', '-') or '-'}")
|
|
108
|
+
console.print(f" Created: {safe.get('CreatedOn', '-')}")
|
|
109
|
+
console.print(f" Modified: {safe.get('ModifiedOn', '-')}")
|
|
110
|
+
|
|
111
|
+
except httpx.HTTPStatusError as e:
|
|
112
|
+
print_api_error(e, "manage secrets")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
except httpx.RequestError as e:
|
|
115
|
+
print_api_error(e, "manage secrets")
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print_api_error(e, "manage secrets")
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@safes_app.command("create")
|
|
123
|
+
def create_safe(
|
|
124
|
+
name: str = typer.Option(..., "--name", "-n", help="Safe name"),
|
|
125
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
|
|
126
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Create a new safe in Secrets Safe."""
|
|
129
|
+
try:
|
|
130
|
+
with get_client() as client:
|
|
131
|
+
client.authenticate()
|
|
132
|
+
safe = client.create_safe(name=name, description=description)
|
|
133
|
+
|
|
134
|
+
if output == "json":
|
|
135
|
+
console.print_json(json.dumps(safe, default=str))
|
|
136
|
+
else:
|
|
137
|
+
console.print(f"[green]Created safe:[/green] {safe.get('Name', name)}")
|
|
138
|
+
console.print(f" ID: {safe.get('Id', 'N/A')}")
|
|
139
|
+
|
|
140
|
+
except httpx.HTTPStatusError as e:
|
|
141
|
+
print_api_error(e, "manage secrets")
|
|
142
|
+
raise typer.Exit(1)
|
|
143
|
+
except httpx.RequestError as e:
|
|
144
|
+
print_api_error(e, "manage secrets")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
print_api_error(e, "manage secrets")
|
|
148
|
+
raise typer.Exit(1)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@safes_app.command("update")
|
|
152
|
+
def update_safe(
|
|
153
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID) to update"),
|
|
154
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="New name"),
|
|
155
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
|
|
156
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Update an existing safe."""
|
|
159
|
+
try:
|
|
160
|
+
with get_client() as client:
|
|
161
|
+
client.authenticate()
|
|
162
|
+
safe = client.update_safe(safe_id=safe_id, name=name, description=description)
|
|
163
|
+
|
|
164
|
+
if output == "json":
|
|
165
|
+
console.print_json(json.dumps(safe, default=str))
|
|
166
|
+
else:
|
|
167
|
+
console.print(f"[green]Updated safe:[/green] {safe.get('Name', 'Unknown')}")
|
|
168
|
+
console.print(f" ID: {safe_id}")
|
|
169
|
+
|
|
170
|
+
except httpx.HTTPStatusError as e:
|
|
171
|
+
print_api_error(e, "manage secrets")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
except httpx.RequestError as e:
|
|
174
|
+
print_api_error(e, "manage secrets")
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
print_api_error(e, "manage secrets")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@safes_app.command("delete")
|
|
182
|
+
def delete_safe(
|
|
183
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID) to delete"),
|
|
184
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Delete a safe from Secrets Safe.
|
|
187
|
+
|
|
188
|
+
Note: Safe must be empty (no folders or secrets) to delete.
|
|
189
|
+
"""
|
|
190
|
+
try:
|
|
191
|
+
with get_client() as client:
|
|
192
|
+
client.authenticate()
|
|
193
|
+
|
|
194
|
+
if not force:
|
|
195
|
+
safe = client.get_safe(safe_id)
|
|
196
|
+
name = safe.get("Name", "Unknown")
|
|
197
|
+
confirm = typer.confirm(
|
|
198
|
+
f"Are you sure you want to delete safe '{name}'?"
|
|
199
|
+
)
|
|
200
|
+
if not confirm:
|
|
201
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
202
|
+
raise typer.Exit(0)
|
|
203
|
+
|
|
204
|
+
client.delete_safe(safe_id)
|
|
205
|
+
console.print(f"[green]Deleted safe: {safe_id}[/green]")
|
|
206
|
+
|
|
207
|
+
except httpx.HTTPStatusError as e:
|
|
208
|
+
print_api_error(e, "manage secrets")
|
|
209
|
+
raise typer.Exit(1)
|
|
210
|
+
except httpx.RequestError as e:
|
|
211
|
+
print_api_error(e, "manage secrets")
|
|
212
|
+
raise typer.Exit(1)
|
|
213
|
+
except Exception as e:
|
|
214
|
+
print_api_error(e, "manage secrets")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@safes_app.command("permissions")
|
|
219
|
+
def list_safe_permissions(
|
|
220
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
|
|
221
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""List permissions assigned to a safe."""
|
|
224
|
+
try:
|
|
225
|
+
with get_client() as client:
|
|
226
|
+
client.authenticate()
|
|
227
|
+
permissions = client.get_safe_permissions(safe_id)
|
|
228
|
+
|
|
229
|
+
if output == "json":
|
|
230
|
+
console.print_json(json.dumps(permissions, default=str))
|
|
231
|
+
else:
|
|
232
|
+
if permissions:
|
|
233
|
+
table = Table(title="Safe Permissions")
|
|
234
|
+
table.add_column("ID", style="dim", max_width=36)
|
|
235
|
+
table.add_column("User ID", style="green")
|
|
236
|
+
table.add_column("Group ID", style="cyan")
|
|
237
|
+
table.add_column("Permissions", style="yellow")
|
|
238
|
+
table.add_column("Expires", style="dim")
|
|
239
|
+
|
|
240
|
+
for perm in permissions:
|
|
241
|
+
flags = perm.get("PermissionFlags") or []
|
|
242
|
+
table.add_row(
|
|
243
|
+
str(perm.get("Id", "-"))[:36],
|
|
244
|
+
str(perm.get("UserId", "-")),
|
|
245
|
+
str(perm.get("GroupId", "-")),
|
|
246
|
+
", ".join(flags) if flags else "-",
|
|
247
|
+
str(perm.get("ExpiresOn", "-"))[:19] if perm.get("ExpiresOn") else "-",
|
|
248
|
+
)
|
|
249
|
+
console.print(table)
|
|
250
|
+
else:
|
|
251
|
+
console.print("[yellow]No permissions found.[/yellow]")
|
|
252
|
+
|
|
253
|
+
except httpx.HTTPStatusError as e:
|
|
254
|
+
print_api_error(e, "manage secrets")
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
except httpx.RequestError as e:
|
|
257
|
+
print_api_error(e, "manage secrets")
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
print_api_error(e, "manage secrets")
|
|
261
|
+
raise typer.Exit(1)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@safes_app.command("permissions-options")
|
|
265
|
+
def list_permissions_options(
|
|
266
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
267
|
+
) -> None:
|
|
268
|
+
"""List all possible safe permission flags."""
|
|
269
|
+
try:
|
|
270
|
+
with get_client() as client:
|
|
271
|
+
client.authenticate()
|
|
272
|
+
options = client.list_safe_permissions_options()
|
|
273
|
+
|
|
274
|
+
if output == "json":
|
|
275
|
+
console.print_json(json.dumps(options, default=str))
|
|
276
|
+
else:
|
|
277
|
+
if options:
|
|
278
|
+
console.print("\n[bold cyan]Available Permission Flags:[/bold cyan]\n")
|
|
279
|
+
for opt in options:
|
|
280
|
+
console.print(f" - {opt}")
|
|
281
|
+
else:
|
|
282
|
+
console.print("[yellow]No permission options found.[/yellow]")
|
|
283
|
+
|
|
284
|
+
except httpx.HTTPStatusError as e:
|
|
285
|
+
print_api_error(e, "manage secrets")
|
|
286
|
+
raise typer.Exit(1)
|
|
287
|
+
except httpx.RequestError as e:
|
|
288
|
+
print_api_error(e, "manage secrets")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
print_api_error(e, "manage secrets")
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@safes_app.command("grant")
|
|
296
|
+
def grant_safe_permission(
|
|
297
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
|
|
298
|
+
user_id: Optional[int] = typer.Option(None, "--user", "-u", help="User ID to grant permission"),
|
|
299
|
+
group_id: Optional[int] = typer.Option(None, "--group", "-g", help="Group ID to grant permission"),
|
|
300
|
+
permissions: str = typer.Option(..., "--permissions", "-p", help="Comma-separated permission flags (e.g., Read,Write)"),
|
|
301
|
+
expires: Optional[str] = typer.Option(None, "--expires", "-e", help="Expiry datetime (ISO format)"),
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Grant permissions to a user or group on a safe.
|
|
304
|
+
|
|
305
|
+
Use 'pws secrets safes permissions-options' to see available permission flags.
|
|
306
|
+
"""
|
|
307
|
+
try:
|
|
308
|
+
if not user_id and not group_id:
|
|
309
|
+
console.print("[red]Error:[/red] Either --user or --group is required")
|
|
310
|
+
raise typer.Exit(1)
|
|
311
|
+
|
|
312
|
+
if user_id and group_id:
|
|
313
|
+
console.print("[red]Error:[/red] Specify either --user or --group, not both")
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
|
|
316
|
+
permission_flags = [p.strip() for p in permissions.split(",")]
|
|
317
|
+
|
|
318
|
+
with get_client() as client:
|
|
319
|
+
client.authenticate()
|
|
320
|
+
|
|
321
|
+
if user_id:
|
|
322
|
+
client.grant_safe_permission_to_user(
|
|
323
|
+
safe_id, user_id, permission_flags, expires_on=expires
|
|
324
|
+
)
|
|
325
|
+
console.print(f"[green]Granted [{permissions}] to user {user_id} on safe[/green]")
|
|
326
|
+
else:
|
|
327
|
+
client.grant_safe_permission_to_group(
|
|
328
|
+
safe_id, group_id, permission_flags, expires_on=expires
|
|
329
|
+
)
|
|
330
|
+
console.print(f"[green]Granted [{permissions}] to group {group_id} on safe[/green]")
|
|
331
|
+
|
|
332
|
+
except httpx.HTTPStatusError as e:
|
|
333
|
+
print_api_error(e, "manage secrets")
|
|
334
|
+
raise typer.Exit(1)
|
|
335
|
+
except httpx.RequestError as e:
|
|
336
|
+
print_api_error(e, "manage secrets")
|
|
337
|
+
raise typer.Exit(1)
|
|
338
|
+
except Exception as e:
|
|
339
|
+
print_api_error(e, "manage secrets")
|
|
340
|
+
raise typer.Exit(1)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@safes_app.command("revoke")
|
|
344
|
+
def revoke_safe_permission(
|
|
345
|
+
safe_id: str = typer.Argument(..., help="Safe ID (GUID)"),
|
|
346
|
+
user_id: Optional[int] = typer.Option(None, "--user", "-u", help="User ID to revoke"),
|
|
347
|
+
group_id: Optional[int] = typer.Option(None, "--group", "-g", help="Group ID to revoke"),
|
|
348
|
+
) -> None:
|
|
349
|
+
"""Revoke all permissions from a user or group on a safe.
|
|
350
|
+
|
|
351
|
+
To revoke, grant an empty permission set using the grant command.
|
|
352
|
+
"""
|
|
353
|
+
try:
|
|
354
|
+
if not user_id and not group_id:
|
|
355
|
+
console.print("[red]Error:[/red] Either --user or --group is required")
|
|
356
|
+
raise typer.Exit(1)
|
|
357
|
+
|
|
358
|
+
with get_client() as client:
|
|
359
|
+
client.authenticate()
|
|
360
|
+
|
|
361
|
+
# To revoke, we grant an empty permission set
|
|
362
|
+
if user_id:
|
|
363
|
+
client.grant_safe_permission_to_user(safe_id, user_id, [])
|
|
364
|
+
console.print(f"[green]Revoked permissions from user {user_id} on safe[/green]")
|
|
365
|
+
else:
|
|
366
|
+
client.grant_safe_permission_to_group(safe_id, group_id, [])
|
|
367
|
+
console.print(f"[green]Revoked permissions from group {group_id} on safe[/green]")
|
|
368
|
+
|
|
369
|
+
except httpx.HTTPStatusError as e:
|
|
370
|
+
print_api_error(e, "manage secrets")
|
|
371
|
+
raise typer.Exit(1)
|
|
372
|
+
except httpx.RequestError as e:
|
|
373
|
+
print_api_error(e, "manage secrets")
|
|
374
|
+
raise typer.Exit(1)
|
|
375
|
+
except Exception as e:
|
|
376
|
+
print_api_error(e, "manage secrets")
|
|
377
|
+
raise typer.Exit(1)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# =============================================================================
|
|
381
|
+
# Folders Commands
|
|
382
|
+
# =============================================================================
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def print_folders_table(folders: list[dict], title: str = "Folders") -> None:
|
|
386
|
+
"""Print folders in a formatted table."""
|
|
387
|
+
table = Table(title=title)
|
|
388
|
+
table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
|
|
389
|
+
table.add_column("Name", style="green")
|
|
390
|
+
table.add_column("Path", style="yellow")
|
|
391
|
+
table.add_column("Description", style="dim")
|
|
392
|
+
|
|
393
|
+
for folder in folders:
|
|
394
|
+
table.add_row(
|
|
395
|
+
folder.get("Id", "")[:36],
|
|
396
|
+
folder.get("Name", ""),
|
|
397
|
+
folder.get("FolderPath", folder.get("Name", "")),
|
|
398
|
+
folder.get("Description", "-") or "-",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
console.print(table)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@folders_app.command("list")
|
|
405
|
+
def list_folders(
|
|
406
|
+
name: Optional[str] = typer.Option(None, "--name", "-n", help="Filter by folder name"),
|
|
407
|
+
path: Optional[str] = typer.Option(None, "--path", "-p", help="Filter by folder path"),
|
|
408
|
+
root_only: bool = typer.Option(False, "--root-only", "-r", help="Show only root folders"),
|
|
409
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
|
|
410
|
+
fetch_all: bool = typer.Option(False, "--all", help="Fetch all results (may be slow)"),
|
|
411
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
412
|
+
) -> None:
|
|
413
|
+
"""List Secrets Safe folders.
|
|
414
|
+
|
|
415
|
+
Examples:
|
|
416
|
+
bt pws secrets folders list # First 50 folders
|
|
417
|
+
bt pws secrets folders list --all # All folders
|
|
418
|
+
bt pws secrets folders list -r # Root folders only
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
with get_client() as client:
|
|
422
|
+
client.authenticate()
|
|
423
|
+
folders = client.list_folders(
|
|
424
|
+
folder_name=name,
|
|
425
|
+
folder_path=path,
|
|
426
|
+
root_only=root_only,
|
|
427
|
+
limit=None if fetch_all else limit,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if output == "json":
|
|
431
|
+
console.print_json(json.dumps(folders, default=str))
|
|
432
|
+
else:
|
|
433
|
+
if folders:
|
|
434
|
+
print_folders_table(folders)
|
|
435
|
+
if not fetch_all and len(folders) == limit:
|
|
436
|
+
console.print(f"[dim]Showing {len(folders)} results. Use --all to fetch all results.[/dim]")
|
|
437
|
+
else:
|
|
438
|
+
console.print("[yellow]No folders found.[/yellow]")
|
|
439
|
+
|
|
440
|
+
except httpx.HTTPStatusError as e:
|
|
441
|
+
print_api_error(e, "manage secrets")
|
|
442
|
+
raise typer.Exit(1)
|
|
443
|
+
except httpx.RequestError as e:
|
|
444
|
+
print_api_error(e, "manage secrets")
|
|
445
|
+
raise typer.Exit(1)
|
|
446
|
+
except Exception as e:
|
|
447
|
+
print_api_error(e, "manage secrets")
|
|
448
|
+
raise typer.Exit(1)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@folders_app.command("get")
|
|
452
|
+
def get_folder(
|
|
453
|
+
folder_id: str = typer.Argument(..., help="Folder ID (GUID)"),
|
|
454
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Get a folder by ID."""
|
|
457
|
+
try:
|
|
458
|
+
with get_client() as client:
|
|
459
|
+
client.authenticate()
|
|
460
|
+
folder = client.get_folder(folder_id)
|
|
461
|
+
|
|
462
|
+
if output == "json":
|
|
463
|
+
console.print_json(json.dumps(folder, default=str))
|
|
464
|
+
else:
|
|
465
|
+
console.print(f"\n[bold cyan]Folder: {folder.get('Name', 'Unknown')}[/bold cyan]\n")
|
|
466
|
+
console.print(f" ID: {folder.get('Id', 'N/A')}")
|
|
467
|
+
console.print(f" Path: {folder.get('FolderPath', '-')}")
|
|
468
|
+
console.print(f" Description: {folder.get('Description', '-') or '-'}")
|
|
469
|
+
console.print(f" Created: {folder.get('CreatedOn', '-')}")
|
|
470
|
+
console.print(f" Modified: {folder.get('ModifiedOn', '-')}")
|
|
471
|
+
|
|
472
|
+
except httpx.HTTPStatusError as e:
|
|
473
|
+
print_api_error(e, "manage secrets")
|
|
474
|
+
raise typer.Exit(1)
|
|
475
|
+
except httpx.RequestError as e:
|
|
476
|
+
print_api_error(e, "manage secrets")
|
|
477
|
+
raise typer.Exit(1)
|
|
478
|
+
except Exception as e:
|
|
479
|
+
print_api_error(e, "manage secrets")
|
|
480
|
+
raise typer.Exit(1)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@folders_app.command("create")
|
|
484
|
+
def create_folder(
|
|
485
|
+
name: str = typer.Option(..., "--name", "-n", help="Folder name"),
|
|
486
|
+
parent: str = typer.Option(..., "--parent", "-p", help="Parent ID (Safe ID for root folder, or Folder ID for subfolder)"),
|
|
487
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
|
|
488
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Create a new folder in Secrets Safe.
|
|
491
|
+
|
|
492
|
+
For root-level folders, use the Safe ID as parent.
|
|
493
|
+
For subfolders, use the parent Folder ID.
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
with get_client() as client:
|
|
497
|
+
client.authenticate()
|
|
498
|
+
folder = client.create_folder(
|
|
499
|
+
name=name,
|
|
500
|
+
parent_id=parent,
|
|
501
|
+
description=description,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
if output == "json":
|
|
505
|
+
console.print_json(json.dumps(folder, default=str))
|
|
506
|
+
else:
|
|
507
|
+
console.print(f"[green]Created folder:[/green] {folder.get('Name', name)}")
|
|
508
|
+
console.print(f" ID: {folder.get('Id', 'N/A')}")
|
|
509
|
+
if folder.get("FolderPath"):
|
|
510
|
+
console.print(f" Path: {folder.get('FolderPath')}")
|
|
511
|
+
|
|
512
|
+
except httpx.HTTPStatusError as e:
|
|
513
|
+
print_api_error(e, "manage secrets")
|
|
514
|
+
raise typer.Exit(1)
|
|
515
|
+
except httpx.RequestError as e:
|
|
516
|
+
print_api_error(e, "manage secrets")
|
|
517
|
+
raise typer.Exit(1)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
print_api_error(e, "manage secrets")
|
|
520
|
+
raise typer.Exit(1)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@folders_app.command("delete")
|
|
524
|
+
def delete_folder(
|
|
525
|
+
folder_id: str = typer.Argument(..., help="Folder ID (GUID) to delete"),
|
|
526
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
527
|
+
) -> None:
|
|
528
|
+
"""Delete a folder from Secrets Safe.
|
|
529
|
+
|
|
530
|
+
Note: Folders containing secrets cannot be deleted.
|
|
531
|
+
"""
|
|
532
|
+
try:
|
|
533
|
+
with get_client() as client:
|
|
534
|
+
client.authenticate()
|
|
535
|
+
|
|
536
|
+
if not force:
|
|
537
|
+
folder = client.get_folder(folder_id)
|
|
538
|
+
name = folder.get("Name", "Unknown")
|
|
539
|
+
confirm = typer.confirm(
|
|
540
|
+
f"Are you sure you want to delete folder '{name}'?"
|
|
541
|
+
)
|
|
542
|
+
if not confirm:
|
|
543
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
544
|
+
raise typer.Exit(0)
|
|
545
|
+
|
|
546
|
+
client.delete_folder(folder_id)
|
|
547
|
+
console.print(f"[green]Deleted folder: {folder_id}[/green]")
|
|
548
|
+
|
|
549
|
+
except httpx.HTTPStatusError as e:
|
|
550
|
+
print_api_error(e, "manage secrets")
|
|
551
|
+
raise typer.Exit(1)
|
|
552
|
+
except httpx.RequestError as e:
|
|
553
|
+
print_api_error(e, "manage secrets")
|
|
554
|
+
raise typer.Exit(1)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
print_api_error(e, "manage secrets")
|
|
557
|
+
raise typer.Exit(1)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# =============================================================================
|
|
561
|
+
# Secrets Commands
|
|
562
|
+
# =============================================================================
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def print_secrets_table(secrets: list[dict], title: str = "Secrets") -> None:
|
|
566
|
+
"""Print secrets in a formatted table."""
|
|
567
|
+
table = Table(title=title)
|
|
568
|
+
table.add_column("ID", style="cyan", no_wrap=True, max_width=36)
|
|
569
|
+
table.add_column("Title", style="green")
|
|
570
|
+
table.add_column("Username", style="yellow")
|
|
571
|
+
table.add_column("Folder", style="blue")
|
|
572
|
+
table.add_column("Modified", style="dim")
|
|
573
|
+
|
|
574
|
+
for secret in secrets:
|
|
575
|
+
table.add_row(
|
|
576
|
+
secret.get("Id", "")[:36],
|
|
577
|
+
secret.get("Title", ""),
|
|
578
|
+
secret.get("Username", "-"),
|
|
579
|
+
secret.get("Folder", secret.get("FolderPath", "-")),
|
|
580
|
+
str(secret.get("ModifiedOn", "-"))[:19],
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
console.print(table)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@secrets_app.command("list")
|
|
587
|
+
def list_secrets(
|
|
588
|
+
folder: Optional[str] = typer.Option(None, "--folder", "-f", help="Folder ID to list secrets from"),
|
|
589
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="Filter by secret title"),
|
|
590
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Maximum results (default: 50)"),
|
|
591
|
+
fetch_all: bool = typer.Option(False, "--all", help="Fetch all results (may be slow)"),
|
|
592
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
593
|
+
) -> None:
|
|
594
|
+
"""List secrets in Secrets Safe.
|
|
595
|
+
|
|
596
|
+
Examples:
|
|
597
|
+
bt pws secrets secrets list # First 50 secrets
|
|
598
|
+
bt pws secrets secrets list --all # All secrets
|
|
599
|
+
bt pws secrets secrets list -f <id> # Secrets in folder
|
|
600
|
+
"""
|
|
601
|
+
try:
|
|
602
|
+
with get_client() as client:
|
|
603
|
+
client.authenticate()
|
|
604
|
+
secrets = client.list_secrets(
|
|
605
|
+
folder_id=folder,
|
|
606
|
+
title=title,
|
|
607
|
+
limit=None if fetch_all else limit,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
if output == "json":
|
|
611
|
+
console.print_json(json.dumps(secrets, default=str))
|
|
612
|
+
else:
|
|
613
|
+
if secrets:
|
|
614
|
+
print_secrets_table(secrets)
|
|
615
|
+
if not fetch_all and len(secrets) == limit:
|
|
616
|
+
console.print(f"[dim]Showing {len(secrets)} results. Use --all to fetch all results.[/dim]")
|
|
617
|
+
else:
|
|
618
|
+
console.print("[yellow]No secrets found.[/yellow]")
|
|
619
|
+
|
|
620
|
+
except httpx.HTTPStatusError as e:
|
|
621
|
+
print_api_error(e, "manage secrets")
|
|
622
|
+
raise typer.Exit(1)
|
|
623
|
+
except httpx.RequestError as e:
|
|
624
|
+
print_api_error(e, "manage secrets")
|
|
625
|
+
raise typer.Exit(1)
|
|
626
|
+
except Exception as e:
|
|
627
|
+
print_api_error(e, "manage secrets")
|
|
628
|
+
raise typer.Exit(1)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@secrets_app.command("get")
|
|
632
|
+
def get_secret(
|
|
633
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
634
|
+
show_password: bool = typer.Option(False, "--show-password", "-p", help="Show password value"),
|
|
635
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
636
|
+
) -> None:
|
|
637
|
+
"""Get a secret by ID (retrieves password)."""
|
|
638
|
+
try:
|
|
639
|
+
with get_client() as client:
|
|
640
|
+
client.authenticate()
|
|
641
|
+
secret = client.get_secret(secret_id)
|
|
642
|
+
|
|
643
|
+
if output == "json":
|
|
644
|
+
if not show_password and "Password" in secret:
|
|
645
|
+
secret = dict(secret)
|
|
646
|
+
secret["Password"] = "********"
|
|
647
|
+
console.print_json(json.dumps(secret, default=str))
|
|
648
|
+
else:
|
|
649
|
+
console.print(f"\n[bold cyan]Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
|
|
650
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
651
|
+
console.print(f" Username: {secret.get('Username', '-')}")
|
|
652
|
+
if show_password:
|
|
653
|
+
console.print(f" Password: {secret.get('Password', '-')}")
|
|
654
|
+
else:
|
|
655
|
+
console.print(" Password: ********")
|
|
656
|
+
console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
|
|
657
|
+
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
658
|
+
if secret.get("Notes"):
|
|
659
|
+
console.print(f" Notes: {secret.get('Notes')}")
|
|
660
|
+
if secret.get("Urls"):
|
|
661
|
+
urls = [u.get("Url", "") for u in secret.get("Urls", [])]
|
|
662
|
+
console.print(f" URLs: {', '.join(urls)}")
|
|
663
|
+
console.print(f" Created: {secret.get('CreatedOn', '-')}")
|
|
664
|
+
console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
|
|
665
|
+
|
|
666
|
+
except httpx.HTTPStatusError as e:
|
|
667
|
+
print_api_error(e, "manage secrets")
|
|
668
|
+
raise typer.Exit(1)
|
|
669
|
+
except httpx.RequestError as e:
|
|
670
|
+
print_api_error(e, "manage secrets")
|
|
671
|
+
raise typer.Exit(1)
|
|
672
|
+
except Exception as e:
|
|
673
|
+
print_api_error(e, "manage secrets")
|
|
674
|
+
raise typer.Exit(1)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@secrets_app.command("show")
|
|
678
|
+
def show_password(
|
|
679
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
680
|
+
) -> None:
|
|
681
|
+
"""Show only the password for a secret (for scripting).
|
|
682
|
+
|
|
683
|
+
WARNING: This outputs the password to stdout for piping to other commands.
|
|
684
|
+
Be careful when running interactively as the password will be visible.
|
|
685
|
+
"""
|
|
686
|
+
import sys
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
with get_client() as client:
|
|
690
|
+
client.authenticate()
|
|
691
|
+
secret = client.get_secret(secret_id)
|
|
692
|
+
password = secret.get("Password", "")
|
|
693
|
+
|
|
694
|
+
# Warn if running interactively (stdout is a terminal)
|
|
695
|
+
if sys.stdout.isatty():
|
|
696
|
+
console.print(
|
|
697
|
+
"[yellow]Warning:[/yellow] Password will be displayed. "
|
|
698
|
+
"Press Ctrl+C to cancel.",
|
|
699
|
+
err=True,
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
# Print just the password for piping
|
|
703
|
+
print(password)
|
|
704
|
+
|
|
705
|
+
except Exception as e:
|
|
706
|
+
console.print(f"[red]Error:[/red] {e}", err=True)
|
|
707
|
+
raise typer.Exit(1)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@secrets_app.command("create")
|
|
711
|
+
def create_secret(
|
|
712
|
+
folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
|
|
713
|
+
title: str = typer.Option(..., "--title", "-t", help="Secret title"),
|
|
714
|
+
username: str = typer.Option(..., "--username", "-u", help="Username"),
|
|
715
|
+
password: str = typer.Option(..., "--password", "-p", help="Password value", prompt=True, hide_input=True),
|
|
716
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
|
|
717
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
|
|
718
|
+
url: Optional[list[str]] = typer.Option(None, "--url", help="Associated URL (can specify multiple)"),
|
|
719
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
720
|
+
) -> None:
|
|
721
|
+
"""Create a new secret in Secrets Safe."""
|
|
722
|
+
try:
|
|
723
|
+
with get_client() as client:
|
|
724
|
+
client.authenticate()
|
|
725
|
+
secret = client.create_secret(
|
|
726
|
+
folder_id=folder,
|
|
727
|
+
title=title,
|
|
728
|
+
username=username,
|
|
729
|
+
password=password,
|
|
730
|
+
description=description,
|
|
731
|
+
notes=notes,
|
|
732
|
+
urls=url,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
if output == "json":
|
|
736
|
+
if "Password" in secret:
|
|
737
|
+
secret = dict(secret)
|
|
738
|
+
secret["Password"] = "********"
|
|
739
|
+
console.print_json(json.dumps(secret, default=str))
|
|
740
|
+
else:
|
|
741
|
+
console.print(f"[green]Created secret:[/green] {secret.get('Title', title)}")
|
|
742
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
743
|
+
console.print(f" Username: {username}")
|
|
744
|
+
console.print(f" Folder: {secret.get('Folder', folder)}")
|
|
745
|
+
|
|
746
|
+
except httpx.HTTPStatusError as e:
|
|
747
|
+
print_api_error(e, "manage secrets")
|
|
748
|
+
raise typer.Exit(1)
|
|
749
|
+
except httpx.RequestError as e:
|
|
750
|
+
print_api_error(e, "manage secrets")
|
|
751
|
+
raise typer.Exit(1)
|
|
752
|
+
except Exception as e:
|
|
753
|
+
print_api_error(e, "manage secrets")
|
|
754
|
+
raise typer.Exit(1)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
@secrets_app.command("update")
|
|
758
|
+
def update_secret(
|
|
759
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
|
|
760
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
|
|
761
|
+
username: Optional[str] = typer.Option(None, "--username", "-u", help="New username"),
|
|
762
|
+
password: Optional[str] = typer.Option(None, "--password", "-p", help="New password"),
|
|
763
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
|
|
764
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
|
|
765
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
766
|
+
) -> None:
|
|
767
|
+
"""Update an existing secret."""
|
|
768
|
+
try:
|
|
769
|
+
with get_client() as client:
|
|
770
|
+
client.authenticate()
|
|
771
|
+
secret = client.update_secret(
|
|
772
|
+
secret_id=secret_id,
|
|
773
|
+
title=title,
|
|
774
|
+
username=username,
|
|
775
|
+
password=password,
|
|
776
|
+
description=description,
|
|
777
|
+
notes=notes,
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
if output == "json":
|
|
781
|
+
if "Password" in secret:
|
|
782
|
+
secret = dict(secret)
|
|
783
|
+
secret["Password"] = "********"
|
|
784
|
+
console.print_json(json.dumps(secret, default=str))
|
|
785
|
+
else:
|
|
786
|
+
console.print(f"[green]Updated secret:[/green] {secret.get('Title', 'Unknown')}")
|
|
787
|
+
console.print(f" ID: {secret_id}")
|
|
788
|
+
|
|
789
|
+
except httpx.HTTPStatusError as e:
|
|
790
|
+
print_api_error(e, "manage secrets")
|
|
791
|
+
raise typer.Exit(1)
|
|
792
|
+
except httpx.RequestError as e:
|
|
793
|
+
print_api_error(e, "manage secrets")
|
|
794
|
+
raise typer.Exit(1)
|
|
795
|
+
except Exception as e:
|
|
796
|
+
print_api_error(e, "manage secrets")
|
|
797
|
+
raise typer.Exit(1)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
@secrets_app.command("delete")
|
|
801
|
+
def delete_secret(
|
|
802
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to delete"),
|
|
803
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
804
|
+
) -> None:
|
|
805
|
+
"""Delete a secret from Secrets Safe."""
|
|
806
|
+
try:
|
|
807
|
+
with get_client() as client:
|
|
808
|
+
client.authenticate()
|
|
809
|
+
|
|
810
|
+
if not force:
|
|
811
|
+
secret = client.get_secret(secret_id)
|
|
812
|
+
title = secret.get("Title", "Unknown")
|
|
813
|
+
confirm = typer.confirm(
|
|
814
|
+
f"Are you sure you want to delete secret '{title}'?"
|
|
815
|
+
)
|
|
816
|
+
if not confirm:
|
|
817
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
818
|
+
raise typer.Exit(0)
|
|
819
|
+
|
|
820
|
+
client.delete_secret(secret_id)
|
|
821
|
+
console.print(f"[green]Deleted secret: {secret_id}[/green]")
|
|
822
|
+
|
|
823
|
+
except httpx.HTTPStatusError as e:
|
|
824
|
+
print_api_error(e, "manage secrets")
|
|
825
|
+
raise typer.Exit(1)
|
|
826
|
+
except httpx.RequestError as e:
|
|
827
|
+
print_api_error(e, "manage secrets")
|
|
828
|
+
raise typer.Exit(1)
|
|
829
|
+
except Exception as e:
|
|
830
|
+
print_api_error(e, "manage secrets")
|
|
831
|
+
raise typer.Exit(1)
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@secrets_app.command("create-text")
|
|
835
|
+
def create_text_secret(
|
|
836
|
+
folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
|
|
837
|
+
title: str = typer.Option(..., "--title", "-t", help="Secret title"),
|
|
838
|
+
text: str = typer.Option(None, "--text", help="Text content (or use --file to read from file)"),
|
|
839
|
+
file: Optional[str] = typer.Option(None, "--file", help="Read text content from file"),
|
|
840
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
|
|
841
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
|
|
842
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
843
|
+
) -> None:
|
|
844
|
+
"""Create a Text type secret (for JSON, configs, etc)."""
|
|
845
|
+
try:
|
|
846
|
+
# Get text content from file or parameter
|
|
847
|
+
if file:
|
|
848
|
+
with open(file, "r") as f:
|
|
849
|
+
text_content = f.read()
|
|
850
|
+
elif text:
|
|
851
|
+
text_content = text
|
|
852
|
+
else:
|
|
853
|
+
console.print("[red]Error:[/red] Either --text or --file is required")
|
|
854
|
+
raise typer.Exit(1)
|
|
855
|
+
|
|
856
|
+
with get_client() as client:
|
|
857
|
+
client.authenticate()
|
|
858
|
+
secret = client.create_text_secret(
|
|
859
|
+
folder_id=folder,
|
|
860
|
+
title=title,
|
|
861
|
+
text=text_content,
|
|
862
|
+
description=description,
|
|
863
|
+
notes=notes,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
if output == "json":
|
|
867
|
+
console.print_json(json.dumps(secret, default=str))
|
|
868
|
+
else:
|
|
869
|
+
console.print(f"[green]Created text secret:[/green] {secret.get('Title', title)}")
|
|
870
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
871
|
+
console.print(f" Folder: {secret.get('Folder', folder)}")
|
|
872
|
+
|
|
873
|
+
except httpx.HTTPStatusError as e:
|
|
874
|
+
print_api_error(e, "manage secrets")
|
|
875
|
+
raise typer.Exit(1)
|
|
876
|
+
except httpx.RequestError as e:
|
|
877
|
+
print_api_error(e, "manage secrets")
|
|
878
|
+
raise typer.Exit(1)
|
|
879
|
+
except Exception as e:
|
|
880
|
+
print_api_error(e, "manage secrets")
|
|
881
|
+
raise typer.Exit(1)
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@secrets_app.command("create-file")
|
|
885
|
+
def create_file_secret(
|
|
886
|
+
folder: str = typer.Option(..., "--folder", "-f", help="Folder ID (GUID) to create secret in"),
|
|
887
|
+
title: str = typer.Option(..., "--title", "-t", help="Secret title"),
|
|
888
|
+
file: str = typer.Option(..., "--file", help="Path to file to upload (max 5MB)"),
|
|
889
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="Description"),
|
|
890
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes"),
|
|
891
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
892
|
+
) -> None:
|
|
893
|
+
"""Create a File type secret (for certificates, keys, etc)."""
|
|
894
|
+
import os
|
|
895
|
+
|
|
896
|
+
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
|
|
897
|
+
|
|
898
|
+
try:
|
|
899
|
+
# Read file content atomically to avoid TOCTOU race condition
|
|
900
|
+
# Check size after reading, not before
|
|
901
|
+
if not os.path.exists(file):
|
|
902
|
+
console.print(f"[red]Error:[/red] File not found: {file}")
|
|
903
|
+
raise typer.Exit(1)
|
|
904
|
+
|
|
905
|
+
with open(file, "rb") as f:
|
|
906
|
+
file_content = f.read(MAX_FILE_SIZE + 1) # Read one extra byte to detect oversized files
|
|
907
|
+
|
|
908
|
+
if len(file_content) > MAX_FILE_SIZE:
|
|
909
|
+
console.print(f"[red]Error:[/red] File too large. Max size is 5MB.")
|
|
910
|
+
raise typer.Exit(1)
|
|
911
|
+
|
|
912
|
+
file_name = os.path.basename(file)
|
|
913
|
+
|
|
914
|
+
with get_client() as client:
|
|
915
|
+
client.authenticate()
|
|
916
|
+
secret = client.create_file_secret(
|
|
917
|
+
folder_id=folder,
|
|
918
|
+
title=title,
|
|
919
|
+
file_content=file_content,
|
|
920
|
+
file_name=file_name,
|
|
921
|
+
description=description,
|
|
922
|
+
notes=notes,
|
|
923
|
+
)
|
|
924
|
+
|
|
925
|
+
if output == "json":
|
|
926
|
+
console.print_json(json.dumps(secret, default=str))
|
|
927
|
+
else:
|
|
928
|
+
console.print(f"[green]Created file secret:[/green] {secret.get('Title', title)}")
|
|
929
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
930
|
+
console.print(f" FileName: {secret.get('FileName', file_name)}")
|
|
931
|
+
console.print(f" Folder: {secret.get('Folder', folder)}")
|
|
932
|
+
|
|
933
|
+
except httpx.HTTPStatusError as e:
|
|
934
|
+
print_api_error(e, "manage secrets")
|
|
935
|
+
raise typer.Exit(1)
|
|
936
|
+
except httpx.RequestError as e:
|
|
937
|
+
print_api_error(e, "manage secrets")
|
|
938
|
+
raise typer.Exit(1)
|
|
939
|
+
except Exception as e:
|
|
940
|
+
print_api_error(e, "manage secrets")
|
|
941
|
+
raise typer.Exit(1)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
@secrets_app.command("get-text")
|
|
945
|
+
def get_text_secret(
|
|
946
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
947
|
+
show_text: bool = typer.Option(False, "--show-text", "-s", help="Show text content"),
|
|
948
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
949
|
+
) -> None:
|
|
950
|
+
"""Get a Text type secret by ID."""
|
|
951
|
+
try:
|
|
952
|
+
with get_client() as client:
|
|
953
|
+
client.authenticate()
|
|
954
|
+
secret = client.get_secret(secret_id)
|
|
955
|
+
|
|
956
|
+
if output == "json":
|
|
957
|
+
if not show_text and "Password" in secret:
|
|
958
|
+
secret = dict(secret)
|
|
959
|
+
secret["Password"] = "********"
|
|
960
|
+
console.print_json(json.dumps(secret, default=str))
|
|
961
|
+
else:
|
|
962
|
+
console.print(f"\n[bold cyan]Text Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
|
|
963
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
964
|
+
console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
|
|
965
|
+
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
966
|
+
if show_text:
|
|
967
|
+
console.print(f" Text Content:\n{secret.get('Password', '-')}")
|
|
968
|
+
else:
|
|
969
|
+
console.print(" Text Content: [dim](use --show-text to reveal)[/dim]")
|
|
970
|
+
console.print(f" Created: {secret.get('CreatedOn', '-')}")
|
|
971
|
+
console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
|
|
972
|
+
|
|
973
|
+
except httpx.HTTPStatusError as e:
|
|
974
|
+
print_api_error(e, "manage secrets")
|
|
975
|
+
raise typer.Exit(1)
|
|
976
|
+
except httpx.RequestError as e:
|
|
977
|
+
print_api_error(e, "manage secrets")
|
|
978
|
+
raise typer.Exit(1)
|
|
979
|
+
except Exception as e:
|
|
980
|
+
print_api_error(e, "manage secrets")
|
|
981
|
+
raise typer.Exit(1)
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
@secrets_app.command("show-text")
|
|
985
|
+
def show_text_content(
|
|
986
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
987
|
+
) -> None:
|
|
988
|
+
"""Show only the text content for a Text secret (for scripting)."""
|
|
989
|
+
try:
|
|
990
|
+
with get_client() as client:
|
|
991
|
+
client.authenticate()
|
|
992
|
+
secret = client.get_secret(secret_id)
|
|
993
|
+
text = secret.get("Password", "")
|
|
994
|
+
print(text)
|
|
995
|
+
|
|
996
|
+
except Exception as e:
|
|
997
|
+
console.print(f"[red]Error:[/red] {e}", err=True)
|
|
998
|
+
raise typer.Exit(1)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@secrets_app.command("update-text")
|
|
1002
|
+
def update_text_secret(
|
|
1003
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
|
|
1004
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
|
|
1005
|
+
text: Optional[str] = typer.Option(None, "--text", help="New text content"),
|
|
1006
|
+
file: Optional[str] = typer.Option(None, "--file", help="Read new text content from file"),
|
|
1007
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
|
|
1008
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
|
|
1009
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
1010
|
+
) -> None:
|
|
1011
|
+
"""Update an existing Text type secret."""
|
|
1012
|
+
try:
|
|
1013
|
+
# Get text content from file or parameter
|
|
1014
|
+
text_content = None
|
|
1015
|
+
if file:
|
|
1016
|
+
with open(file, "r") as f:
|
|
1017
|
+
text_content = f.read()
|
|
1018
|
+
elif text:
|
|
1019
|
+
text_content = text
|
|
1020
|
+
|
|
1021
|
+
with get_client() as client:
|
|
1022
|
+
client.authenticate()
|
|
1023
|
+
secret = client.update_text_secret(
|
|
1024
|
+
secret_id=secret_id,
|
|
1025
|
+
text=text_content,
|
|
1026
|
+
title=title,
|
|
1027
|
+
description=description,
|
|
1028
|
+
notes=notes,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
if output == "json":
|
|
1032
|
+
console.print_json(json.dumps(secret, default=str))
|
|
1033
|
+
else:
|
|
1034
|
+
console.print(f"[green]Updated text secret:[/green] {secret.get('Title', 'Unknown')}")
|
|
1035
|
+
console.print(f" ID: {secret_id}")
|
|
1036
|
+
|
|
1037
|
+
except httpx.HTTPStatusError as e:
|
|
1038
|
+
print_api_error(e, "manage secrets")
|
|
1039
|
+
raise typer.Exit(1)
|
|
1040
|
+
except httpx.RequestError as e:
|
|
1041
|
+
print_api_error(e, "manage secrets")
|
|
1042
|
+
raise typer.Exit(1)
|
|
1043
|
+
except Exception as e:
|
|
1044
|
+
print_api_error(e, "manage secrets")
|
|
1045
|
+
raise typer.Exit(1)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@secrets_app.command("get-file")
|
|
1049
|
+
def get_file_secret(
|
|
1050
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
1051
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
1052
|
+
) -> None:
|
|
1053
|
+
"""Get a File type secret metadata by ID."""
|
|
1054
|
+
try:
|
|
1055
|
+
with get_client() as client:
|
|
1056
|
+
client.authenticate()
|
|
1057
|
+
secret = client.get_secret(secret_id)
|
|
1058
|
+
|
|
1059
|
+
if output == "json":
|
|
1060
|
+
# Don't include file content in json output
|
|
1061
|
+
secret = dict(secret)
|
|
1062
|
+
secret.pop("Password", None)
|
|
1063
|
+
console.print_json(json.dumps(secret, default=str))
|
|
1064
|
+
else:
|
|
1065
|
+
console.print(f"\n[bold cyan]File Secret: {secret.get('Title', 'Unknown')}[/bold cyan]\n")
|
|
1066
|
+
console.print(f" ID: {secret.get('Id', 'N/A')}")
|
|
1067
|
+
console.print(f" FileName: {secret.get('FileName', '-')}")
|
|
1068
|
+
console.print(f" Folder: {secret.get('Folder', secret.get('FolderPath', '-'))}")
|
|
1069
|
+
console.print(f" Description: {secret.get('Description', '-') or '-'}")
|
|
1070
|
+
console.print(f" Created: {secret.get('CreatedOn', '-')}")
|
|
1071
|
+
console.print(f" Modified: {secret.get('ModifiedOn', '-')}")
|
|
1072
|
+
console.print("\n [dim]Use 'pws secrets secrets download' to download the file content[/dim]")
|
|
1073
|
+
|
|
1074
|
+
except httpx.HTTPStatusError as e:
|
|
1075
|
+
print_api_error(e, "manage secrets")
|
|
1076
|
+
raise typer.Exit(1)
|
|
1077
|
+
except httpx.RequestError as e:
|
|
1078
|
+
print_api_error(e, "manage secrets")
|
|
1079
|
+
raise typer.Exit(1)
|
|
1080
|
+
except Exception as e:
|
|
1081
|
+
print_api_error(e, "manage secrets")
|
|
1082
|
+
raise typer.Exit(1)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def _sanitize_filename(filename: str) -> str:
|
|
1086
|
+
"""Sanitize filename to prevent path traversal attacks.
|
|
1087
|
+
|
|
1088
|
+
Removes path separators and parent directory references.
|
|
1089
|
+
"""
|
|
1090
|
+
import os
|
|
1091
|
+
# Get just the basename to strip any directory components
|
|
1092
|
+
safe_name = os.path.basename(filename)
|
|
1093
|
+
# Remove any remaining path traversal attempts
|
|
1094
|
+
safe_name = safe_name.replace("..", "").replace("/", "").replace("\\", "")
|
|
1095
|
+
# If nothing left, use a default name
|
|
1096
|
+
if not safe_name or safe_name.startswith("."):
|
|
1097
|
+
safe_name = "downloaded_file"
|
|
1098
|
+
return safe_name
|
|
1099
|
+
|
|
1100
|
+
|
|
1101
|
+
@secrets_app.command("download")
|
|
1102
|
+
def download_file_secret(
|
|
1103
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
1104
|
+
output_path: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path (uses original filename if not specified)"),
|
|
1105
|
+
) -> None:
|
|
1106
|
+
"""Download a File type secret to disk."""
|
|
1107
|
+
import os
|
|
1108
|
+
|
|
1109
|
+
try:
|
|
1110
|
+
with get_client() as client:
|
|
1111
|
+
client.authenticate()
|
|
1112
|
+
# Get metadata first for filename
|
|
1113
|
+
secret = client.get_secret(secret_id)
|
|
1114
|
+
# Sanitize filename from API to prevent path traversal
|
|
1115
|
+
file_name = _sanitize_filename(secret.get("FileName", "downloaded_file"))
|
|
1116
|
+
|
|
1117
|
+
# Get file content
|
|
1118
|
+
content = client.get_file_content(secret_id)
|
|
1119
|
+
|
|
1120
|
+
# Determine output path
|
|
1121
|
+
if output_path:
|
|
1122
|
+
# User-specified path is trusted (they control where to write)
|
|
1123
|
+
out_file = output_path
|
|
1124
|
+
else:
|
|
1125
|
+
# API-provided filename must be sanitized
|
|
1126
|
+
out_file = file_name
|
|
1127
|
+
|
|
1128
|
+
# Resolve to absolute path and verify it's in expected location
|
|
1129
|
+
out_file = os.path.abspath(out_file)
|
|
1130
|
+
|
|
1131
|
+
# Write to disk
|
|
1132
|
+
with open(out_file, "wb") as f:
|
|
1133
|
+
f.write(content)
|
|
1134
|
+
|
|
1135
|
+
console.print(f"[green]Downloaded:[/green] {out_file}")
|
|
1136
|
+
console.print(f" Size: {len(content)} bytes")
|
|
1137
|
+
|
|
1138
|
+
except httpx.HTTPStatusError as e:
|
|
1139
|
+
print_api_error(e, "manage secrets")
|
|
1140
|
+
raise typer.Exit(1)
|
|
1141
|
+
except httpx.RequestError as e:
|
|
1142
|
+
print_api_error(e, "manage secrets")
|
|
1143
|
+
raise typer.Exit(1)
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
print_api_error(e, "manage secrets")
|
|
1146
|
+
raise typer.Exit(1)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
@secrets_app.command("update-file")
|
|
1150
|
+
def update_file_secret(
|
|
1151
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to update"),
|
|
1152
|
+
title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
|
|
1153
|
+
file: Optional[str] = typer.Option(None, "--file", help="New file to upload"),
|
|
1154
|
+
description: Optional[str] = typer.Option(None, "--description", "-d", help="New description"),
|
|
1155
|
+
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="New notes"),
|
|
1156
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
1157
|
+
) -> None:
|
|
1158
|
+
"""Update an existing File type secret."""
|
|
1159
|
+
import os
|
|
1160
|
+
|
|
1161
|
+
try:
|
|
1162
|
+
file_content = None
|
|
1163
|
+
file_name = None
|
|
1164
|
+
|
|
1165
|
+
if file:
|
|
1166
|
+
if not os.path.exists(file):
|
|
1167
|
+
console.print(f"[red]Error:[/red] File not found: {file}")
|
|
1168
|
+
raise typer.Exit(1)
|
|
1169
|
+
|
|
1170
|
+
file_size = os.path.getsize(file)
|
|
1171
|
+
if file_size > 5 * 1024 * 1024:
|
|
1172
|
+
console.print(f"[red]Error:[/red] File too large ({file_size} bytes). Max size is 5MB.")
|
|
1173
|
+
raise typer.Exit(1)
|
|
1174
|
+
|
|
1175
|
+
with open(file, "rb") as f:
|
|
1176
|
+
file_content = f.read()
|
|
1177
|
+
file_name = os.path.basename(file)
|
|
1178
|
+
|
|
1179
|
+
with get_client() as client:
|
|
1180
|
+
client.authenticate()
|
|
1181
|
+
secret = client.update_file_secret(
|
|
1182
|
+
secret_id=secret_id,
|
|
1183
|
+
file_content=file_content,
|
|
1184
|
+
file_name=file_name,
|
|
1185
|
+
title=title,
|
|
1186
|
+
description=description,
|
|
1187
|
+
notes=notes,
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
if output == "json":
|
|
1191
|
+
console.print_json(json.dumps(secret, default=str))
|
|
1192
|
+
else:
|
|
1193
|
+
console.print(f"[green]Updated file secret:[/green] {secret.get('Title', 'Unknown')}")
|
|
1194
|
+
console.print(f" ID: {secret_id}")
|
|
1195
|
+
|
|
1196
|
+
except httpx.HTTPStatusError as e:
|
|
1197
|
+
print_api_error(e, "manage secrets")
|
|
1198
|
+
raise typer.Exit(1)
|
|
1199
|
+
except httpx.RequestError as e:
|
|
1200
|
+
print_api_error(e, "manage secrets")
|
|
1201
|
+
raise typer.Exit(1)
|
|
1202
|
+
except Exception as e:
|
|
1203
|
+
print_api_error(e, "manage secrets")
|
|
1204
|
+
raise typer.Exit(1)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
@secrets_app.command("move")
|
|
1208
|
+
def move_secret(
|
|
1209
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to move"),
|
|
1210
|
+
folder: str = typer.Option(..., "--folder", "-f", help="Target folder ID (GUID)"),
|
|
1211
|
+
) -> None:
|
|
1212
|
+
"""Move a secret to a different folder."""
|
|
1213
|
+
try:
|
|
1214
|
+
with get_client() as client:
|
|
1215
|
+
client.authenticate()
|
|
1216
|
+
result = client.move_secret(secret_id, folder)
|
|
1217
|
+
|
|
1218
|
+
console.print(f"[green]Moved secret:[/green] {secret_id}")
|
|
1219
|
+
console.print(f" To folder: {folder}")
|
|
1220
|
+
|
|
1221
|
+
except httpx.HTTPStatusError as e:
|
|
1222
|
+
print_api_error(e, "manage secrets")
|
|
1223
|
+
raise typer.Exit(1)
|
|
1224
|
+
except httpx.RequestError as e:
|
|
1225
|
+
print_api_error(e, "manage secrets")
|
|
1226
|
+
raise typer.Exit(1)
|
|
1227
|
+
except Exception as e:
|
|
1228
|
+
print_api_error(e, "manage secrets")
|
|
1229
|
+
raise typer.Exit(1)
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
# =============================================================================
|
|
1233
|
+
# Shares Commands
|
|
1234
|
+
# =============================================================================
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
@secrets_app.command("shares")
|
|
1238
|
+
def list_shares(
|
|
1239
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
1240
|
+
output: str = typer.Option("table", "--output", "-o", help="Output format: table or json"),
|
|
1241
|
+
) -> None:
|
|
1242
|
+
"""List all shares of a secret."""
|
|
1243
|
+
try:
|
|
1244
|
+
with get_client() as client:
|
|
1245
|
+
client.authenticate()
|
|
1246
|
+
shares = client.list_shares(secret_id)
|
|
1247
|
+
|
|
1248
|
+
if output == "json":
|
|
1249
|
+
console.print_json(json.dumps(shares, default=str))
|
|
1250
|
+
else:
|
|
1251
|
+
if shares:
|
|
1252
|
+
table = Table(title="Secret Shares")
|
|
1253
|
+
table.add_column("Share ID", style="cyan")
|
|
1254
|
+
table.add_column("Folder ID", style="green")
|
|
1255
|
+
table.add_column("Folder", style="yellow")
|
|
1256
|
+
|
|
1257
|
+
for share in shares:
|
|
1258
|
+
table.add_row(
|
|
1259
|
+
share.get("Id", "")[:36],
|
|
1260
|
+
share.get("FolderId", "")[:36],
|
|
1261
|
+
share.get("FolderName", "-"),
|
|
1262
|
+
)
|
|
1263
|
+
console.print(table)
|
|
1264
|
+
else:
|
|
1265
|
+
console.print("[yellow]No shares found for this secret.[/yellow]")
|
|
1266
|
+
|
|
1267
|
+
except httpx.HTTPStatusError as e:
|
|
1268
|
+
print_api_error(e, "manage secrets")
|
|
1269
|
+
raise typer.Exit(1)
|
|
1270
|
+
except httpx.RequestError as e:
|
|
1271
|
+
print_api_error(e, "manage secrets")
|
|
1272
|
+
raise typer.Exit(1)
|
|
1273
|
+
except Exception as e:
|
|
1274
|
+
print_api_error(e, "manage secrets")
|
|
1275
|
+
raise typer.Exit(1)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
@secrets_app.command("share")
|
|
1279
|
+
def share_secret(
|
|
1280
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID) to share"),
|
|
1281
|
+
folder: str = typer.Option(..., "--folder", "-f", help="Target folder ID (GUID) to share to"),
|
|
1282
|
+
) -> None:
|
|
1283
|
+
"""Share a secret to another folder."""
|
|
1284
|
+
try:
|
|
1285
|
+
with get_client() as client:
|
|
1286
|
+
client.authenticate()
|
|
1287
|
+
result = client.share_secret(secret_id, folder)
|
|
1288
|
+
|
|
1289
|
+
console.print(f"[green]Shared secret:[/green] {secret_id}")
|
|
1290
|
+
console.print(f" To folder: {folder}")
|
|
1291
|
+
if result.get("Id"):
|
|
1292
|
+
console.print(f" Share ID: {result.get('Id')}")
|
|
1293
|
+
|
|
1294
|
+
except httpx.HTTPStatusError as e:
|
|
1295
|
+
print_api_error(e, "manage secrets")
|
|
1296
|
+
raise typer.Exit(1)
|
|
1297
|
+
except httpx.RequestError as e:
|
|
1298
|
+
print_api_error(e, "manage secrets")
|
|
1299
|
+
raise typer.Exit(1)
|
|
1300
|
+
except Exception as e:
|
|
1301
|
+
print_api_error(e, "manage secrets")
|
|
1302
|
+
raise typer.Exit(1)
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
@secrets_app.command("unshare")
|
|
1306
|
+
def unshare_secret(
|
|
1307
|
+
secret_id: str = typer.Argument(..., help="Secret ID (GUID)"),
|
|
1308
|
+
share_id: Optional[str] = typer.Option(None, "--share-id", "-s", help="Specific share ID to remove"),
|
|
1309
|
+
all_shares: bool = typer.Option(False, "--all", "-a", help="Remove all shares"),
|
|
1310
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
1311
|
+
) -> None:
|
|
1312
|
+
"""Remove a share from a secret."""
|
|
1313
|
+
try:
|
|
1314
|
+
with get_client() as client:
|
|
1315
|
+
client.authenticate()
|
|
1316
|
+
|
|
1317
|
+
if all_shares:
|
|
1318
|
+
if not force:
|
|
1319
|
+
confirm = typer.confirm(
|
|
1320
|
+
f"Are you sure you want to remove ALL shares from secret {secret_id}?"
|
|
1321
|
+
)
|
|
1322
|
+
if not confirm:
|
|
1323
|
+
console.print("[yellow]Cancelled.[/yellow]")
|
|
1324
|
+
raise typer.Exit(0)
|
|
1325
|
+
|
|
1326
|
+
client.delete_all_shares(secret_id)
|
|
1327
|
+
console.print(f"[green]Removed all shares from secret:[/green] {secret_id}")
|
|
1328
|
+
elif share_id:
|
|
1329
|
+
client.delete_share(secret_id, share_id)
|
|
1330
|
+
console.print(f"[green]Removed share:[/green] {share_id}")
|
|
1331
|
+
else:
|
|
1332
|
+
console.print("[red]Error:[/red] Either --share-id or --all is required")
|
|
1333
|
+
raise typer.Exit(1)
|
|
1334
|
+
|
|
1335
|
+
except httpx.HTTPStatusError as e:
|
|
1336
|
+
print_api_error(e, "manage secrets")
|
|
1337
|
+
raise typer.Exit(1)
|
|
1338
|
+
except httpx.RequestError as e:
|
|
1339
|
+
print_api_error(e, "manage secrets")
|
|
1340
|
+
raise typer.Exit(1)
|
|
1341
|
+
except Exception as e:
|
|
1342
|
+
print_api_error(e, "manage secrets")
|
|
1343
|
+
raise typer.Exit(1)
|