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,1786 @@
|
|
|
1
|
+
"""Password Safe API client for credential and account management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional, TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .base import PasswordSafeClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PasswordSafeMixin:
|
|
10
|
+
"""Mixin class providing Password Safe API methods.
|
|
11
|
+
|
|
12
|
+
Add to PasswordSafeClient to provide managed account, credential checkout,
|
|
13
|
+
request, and session operations.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# =========================================================================
|
|
17
|
+
# Managed Accounts
|
|
18
|
+
# =========================================================================
|
|
19
|
+
|
|
20
|
+
def list_managed_accounts(
|
|
21
|
+
self: "PasswordSafeClient",
|
|
22
|
+
system_id: Optional[int] = None,
|
|
23
|
+
account_name: Optional[str] = None,
|
|
24
|
+
limit: Optional[int] = None,
|
|
25
|
+
offset: Optional[int] = None,
|
|
26
|
+
api_enabled: Optional[bool] = None,
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
"""List managed accounts.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
system_id: Optional system ID filter
|
|
32
|
+
account_name: Filter by account name (exact match)
|
|
33
|
+
limit: Maximum number of results (uses pagination if not set)
|
|
34
|
+
offset: Number of results to skip (for pagination)
|
|
35
|
+
api_enabled: Filter by API enabled status
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of managed account objects
|
|
39
|
+
"""
|
|
40
|
+
params = {}
|
|
41
|
+
if account_name:
|
|
42
|
+
params["accountName"] = account_name
|
|
43
|
+
if api_enabled is not None:
|
|
44
|
+
params["apiEnabled"] = api_enabled
|
|
45
|
+
|
|
46
|
+
# Determine endpoint
|
|
47
|
+
if system_id:
|
|
48
|
+
endpoint = f"/ManagedSystems/{system_id}/ManagedAccounts"
|
|
49
|
+
else:
|
|
50
|
+
endpoint = "/ManagedAccounts"
|
|
51
|
+
|
|
52
|
+
# If limit is set, do a single request (no full pagination)
|
|
53
|
+
if limit is not None:
|
|
54
|
+
params["limit"] = limit
|
|
55
|
+
if offset:
|
|
56
|
+
params["offset"] = offset
|
|
57
|
+
result = self.get(endpoint, params=params)
|
|
58
|
+
# Handle different response formats
|
|
59
|
+
if isinstance(result, list):
|
|
60
|
+
return result
|
|
61
|
+
if isinstance(result, dict):
|
|
62
|
+
return result.get("Data", result.get("results", []))
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
# Otherwise, paginate through all results
|
|
66
|
+
return self.paginate(endpoint, params=params)
|
|
67
|
+
|
|
68
|
+
def get_managed_account(
|
|
69
|
+
self: "PasswordSafeClient", account_id: int
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Get a managed account by ID.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
account_id: Managed account ID
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Managed account object
|
|
78
|
+
"""
|
|
79
|
+
return self.get(f"/ManagedAccounts/{account_id}")
|
|
80
|
+
|
|
81
|
+
def get_managed_account_by_name(
|
|
82
|
+
self: "PasswordSafeClient",
|
|
83
|
+
system_name: str,
|
|
84
|
+
account_name: str,
|
|
85
|
+
) -> Optional[dict[str, Any]]:
|
|
86
|
+
"""Get a managed account by system and account name.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
system_name: Name of the managed system
|
|
90
|
+
account_name: Name of the account
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Managed account object or None if not found
|
|
94
|
+
"""
|
|
95
|
+
# First find the system
|
|
96
|
+
systems = self.list_managed_systems(search=system_name)
|
|
97
|
+
system = None
|
|
98
|
+
for s in systems:
|
|
99
|
+
if s.get("SystemName", "").lower() == system_name.lower():
|
|
100
|
+
system = s
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
if not system:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Then find the account in that system
|
|
107
|
+
accounts = self.list_managed_accounts(system_id=system["ManagedSystemID"])
|
|
108
|
+
for account in accounts:
|
|
109
|
+
if account.get("AccountName", "").lower() == account_name.lower():
|
|
110
|
+
return account
|
|
111
|
+
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def create_managed_account(
|
|
115
|
+
self: "PasswordSafeClient",
|
|
116
|
+
system_id: int,
|
|
117
|
+
account_name: str,
|
|
118
|
+
password: Optional[str] = None,
|
|
119
|
+
domain_name: Optional[str] = None,
|
|
120
|
+
description: Optional[str] = None,
|
|
121
|
+
api_enabled: bool = True,
|
|
122
|
+
auto_management_flag: bool = True,
|
|
123
|
+
check_password_flag: bool = True,
|
|
124
|
+
change_password_after_any_release_flag: bool = False,
|
|
125
|
+
reset_password_on_mismatch_flag: bool = False,
|
|
126
|
+
change_frequency_type: Optional[str] = None,
|
|
127
|
+
change_frequency_days: Optional[int] = None,
|
|
128
|
+
change_time: Optional[str] = None,
|
|
129
|
+
next_change_date: Optional[str] = None,
|
|
130
|
+
password_rule_id: Optional[int] = None,
|
|
131
|
+
dss_key_rule_id: Optional[int] = None,
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Create a new managed account.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
system_id: System ID to add account to
|
|
137
|
+
account_name: Account username
|
|
138
|
+
password: Initial password (optional, can be discovered)
|
|
139
|
+
domain_name: Domain for the account
|
|
140
|
+
description: Account description
|
|
141
|
+
api_enabled: Enable API access for this account
|
|
142
|
+
auto_management_flag: Enable automatic password management
|
|
143
|
+
check_password_flag: Enable password checking
|
|
144
|
+
change_password_after_any_release_flag: Change password after release
|
|
145
|
+
reset_password_on_mismatch_flag: Reset on password mismatch
|
|
146
|
+
change_frequency_type: Password change frequency type
|
|
147
|
+
change_frequency_days: Days between password changes
|
|
148
|
+
change_time: Time of day for password changes
|
|
149
|
+
next_change_date: Next scheduled password change
|
|
150
|
+
password_rule_id: Password rule to use
|
|
151
|
+
dss_key_rule_id: DSS key rule to use
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Created managed account object
|
|
155
|
+
"""
|
|
156
|
+
data: dict[str, Any] = {
|
|
157
|
+
"AccountName": account_name,
|
|
158
|
+
"ApiEnabled": api_enabled,
|
|
159
|
+
"AutoManagementFlag": auto_management_flag,
|
|
160
|
+
"CheckPasswordFlag": check_password_flag,
|
|
161
|
+
"ChangePasswordAfterAnyReleaseFlag": change_password_after_any_release_flag,
|
|
162
|
+
"ResetPasswordOnMismatchFlag": reset_password_on_mismatch_flag,
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if password:
|
|
166
|
+
data["Password"] = password
|
|
167
|
+
if domain_name:
|
|
168
|
+
data["DomainName"] = domain_name
|
|
169
|
+
if description:
|
|
170
|
+
data["Description"] = description
|
|
171
|
+
if change_frequency_type:
|
|
172
|
+
data["ChangeFrequencyType"] = change_frequency_type
|
|
173
|
+
if change_frequency_days is not None:
|
|
174
|
+
data["ChangeFrequencyDays"] = change_frequency_days
|
|
175
|
+
if change_time:
|
|
176
|
+
data["ChangeTime"] = change_time
|
|
177
|
+
if next_change_date:
|
|
178
|
+
data["NextChangeDate"] = next_change_date
|
|
179
|
+
if password_rule_id is not None:
|
|
180
|
+
data["PasswordRuleID"] = password_rule_id
|
|
181
|
+
if dss_key_rule_id is not None:
|
|
182
|
+
data["DSSKeyRuleID"] = dss_key_rule_id
|
|
183
|
+
|
|
184
|
+
return self.post(f"/ManagedSystems/{system_id}/ManagedAccounts", json=data)
|
|
185
|
+
|
|
186
|
+
def update_managed_account(
|
|
187
|
+
self: "PasswordSafeClient",
|
|
188
|
+
account_id: int,
|
|
189
|
+
**kwargs: Any,
|
|
190
|
+
) -> dict[str, Any]:
|
|
191
|
+
"""Update a managed account.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
account_id: Account ID to update
|
|
195
|
+
**kwargs: Fields to update
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Updated managed account object
|
|
199
|
+
"""
|
|
200
|
+
# Convert snake_case to PascalCase for API
|
|
201
|
+
data = {}
|
|
202
|
+
for key, value in kwargs.items():
|
|
203
|
+
if value is not None:
|
|
204
|
+
pascal_key = "".join(word.capitalize() for word in key.split("_"))
|
|
205
|
+
data[pascal_key] = value
|
|
206
|
+
|
|
207
|
+
return self.put(f"/ManagedAccounts/{account_id}", json=data)
|
|
208
|
+
|
|
209
|
+
def delete_managed_account(
|
|
210
|
+
self: "PasswordSafeClient", account_id: int
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
"""Delete a managed account.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
account_id: Account ID to delete
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Empty response on success
|
|
219
|
+
"""
|
|
220
|
+
return self.delete(f"/ManagedAccounts/{account_id}")
|
|
221
|
+
|
|
222
|
+
def set_managed_account_password(
|
|
223
|
+
self: "PasswordSafeClient",
|
|
224
|
+
account_id: int,
|
|
225
|
+
password: str,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Set the password for a managed account.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
account_id: Account ID
|
|
231
|
+
password: New password
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Response from password update
|
|
235
|
+
"""
|
|
236
|
+
return self.put(
|
|
237
|
+
f"/ManagedAccounts/{account_id}/Credentials",
|
|
238
|
+
json={"Password": password},
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def change_managed_account_password(
|
|
242
|
+
self: "PasswordSafeClient",
|
|
243
|
+
account_id: int,
|
|
244
|
+
) -> dict[str, Any]:
|
|
245
|
+
"""Trigger password change/rotation for a managed account.
|
|
246
|
+
|
|
247
|
+
This initiates an immediate password change using the configured
|
|
248
|
+
password rule. The system will generate a new password and update
|
|
249
|
+
it on the target system using the functional account.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
account_id: Account ID to rotate
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Response from password change request
|
|
256
|
+
"""
|
|
257
|
+
# Use POST to trigger immediate password change
|
|
258
|
+
return self.post(f"/ManagedAccounts/{account_id}/Credentials/Change")
|
|
259
|
+
|
|
260
|
+
# =========================================================================
|
|
261
|
+
# Credential Requests (Checkout/Checkin)
|
|
262
|
+
# =========================================================================
|
|
263
|
+
|
|
264
|
+
def list_requests(
|
|
265
|
+
self: "PasswordSafeClient",
|
|
266
|
+
status: Optional[str] = None,
|
|
267
|
+
limit: Optional[int] = None,
|
|
268
|
+
) -> list[dict[str, Any]]:
|
|
269
|
+
"""List credential release requests.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
status: Filter by status (pending, approved, denied, expired)
|
|
273
|
+
limit: Maximum number of results
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
List of request objects
|
|
277
|
+
"""
|
|
278
|
+
params = {}
|
|
279
|
+
if status:
|
|
280
|
+
params["status"] = status
|
|
281
|
+
if limit:
|
|
282
|
+
params["limit"] = limit
|
|
283
|
+
|
|
284
|
+
return self.paginate("/Requests", params=params)
|
|
285
|
+
|
|
286
|
+
def get_request(self: "PasswordSafeClient", request_id: int) -> dict[str, Any]:
|
|
287
|
+
"""Get a request by ID.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
request_id: Request ID
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Request object
|
|
294
|
+
"""
|
|
295
|
+
return self.get(f"/Requests/{request_id}")
|
|
296
|
+
|
|
297
|
+
def create_request(
|
|
298
|
+
self: "PasswordSafeClient",
|
|
299
|
+
account_id: int,
|
|
300
|
+
system_id: Optional[int] = None,
|
|
301
|
+
duration_minutes: int = 60,
|
|
302
|
+
reason: Optional[str] = None,
|
|
303
|
+
access_type: str = "View",
|
|
304
|
+
conflict_option: str = "reuse",
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Create a credential release request (checkout).
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
account_id: Managed account ID to request
|
|
310
|
+
system_id: Managed system ID (required by API)
|
|
311
|
+
duration_minutes: Duration in minutes
|
|
312
|
+
reason: Reason for the request
|
|
313
|
+
access_type: Type of access (View, RDP, SSH, etc.)
|
|
314
|
+
conflict_option: How to handle conflicts (reuse, renew)
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
Created request object with RequestID
|
|
318
|
+
"""
|
|
319
|
+
data: dict[str, Any] = {
|
|
320
|
+
"AccountID": account_id,
|
|
321
|
+
"DurationMinutes": duration_minutes,
|
|
322
|
+
"AccessType": access_type,
|
|
323
|
+
"ConflictOption": conflict_option,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if system_id:
|
|
327
|
+
data["SystemID"] = system_id
|
|
328
|
+
|
|
329
|
+
if reason:
|
|
330
|
+
data["Reason"] = reason
|
|
331
|
+
|
|
332
|
+
result = self.post("/Requests", json=data)
|
|
333
|
+
# API returns just the request ID, wrap in dict for consistency
|
|
334
|
+
if isinstance(result, int):
|
|
335
|
+
return {"RequestID": result}
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
def checkout_credential(
|
|
339
|
+
self: "PasswordSafeClient",
|
|
340
|
+
system: str,
|
|
341
|
+
account: str,
|
|
342
|
+
duration: int = 60,
|
|
343
|
+
reason: Optional[str] = None,
|
|
344
|
+
access_type: str = "View",
|
|
345
|
+
) -> dict[str, Any]:
|
|
346
|
+
"""Checkout a credential by system and account name.
|
|
347
|
+
|
|
348
|
+
Convenience method that finds the account and creates a request.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
system: Managed system name
|
|
352
|
+
account: Account name
|
|
353
|
+
duration: Duration in minutes
|
|
354
|
+
reason: Reason for the request
|
|
355
|
+
access_type: Type of access
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
Request object with RequestID
|
|
359
|
+
|
|
360
|
+
Raises:
|
|
361
|
+
ValueError: If system or account not found
|
|
362
|
+
"""
|
|
363
|
+
# Find the managed account
|
|
364
|
+
account_obj = self.get_managed_account_by_name(system, account)
|
|
365
|
+
if not account_obj:
|
|
366
|
+
raise ValueError(f"Account '{account}' not found on system '{system}'")
|
|
367
|
+
|
|
368
|
+
account_id = account_obj.get("ManagedAccountID")
|
|
369
|
+
system_id = account_obj.get("ManagedSystemID")
|
|
370
|
+
if not account_id:
|
|
371
|
+
raise ValueError(f"Account '{account}' has no ManagedAccountID")
|
|
372
|
+
|
|
373
|
+
return self.create_request(
|
|
374
|
+
account_id=account_id,
|
|
375
|
+
system_id=system_id,
|
|
376
|
+
duration_minutes=duration,
|
|
377
|
+
reason=reason,
|
|
378
|
+
access_type=access_type,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def get_credential(self: "PasswordSafeClient", request_id: int) -> dict[str, Any]:
|
|
382
|
+
"""Get the credential (password) for an approved request.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
request_id: Request ID
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Credential object with Password field
|
|
389
|
+
"""
|
|
390
|
+
result = self.get(f"/Credentials/{request_id}")
|
|
391
|
+
# API returns password as plain string, wrap in dict for consistency
|
|
392
|
+
if isinstance(result, str):
|
|
393
|
+
return {"Password": result}
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
def checkin_request(
|
|
397
|
+
self: "PasswordSafeClient", request_id: int, reason: Optional[str] = None
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Check in a credential request.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
request_id: Request ID to check in
|
|
403
|
+
reason: Optional reason for checkin
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Empty response on success
|
|
407
|
+
"""
|
|
408
|
+
data: dict[str, Any] = {}
|
|
409
|
+
if reason:
|
|
410
|
+
data["Reason"] = reason
|
|
411
|
+
return self.put(f"/Requests/{request_id}/Checkin", json=data)
|
|
412
|
+
|
|
413
|
+
def rotate_on_checkin(self: "PasswordSafeClient", request_id: int) -> dict[str, Any]:
|
|
414
|
+
"""Mark a request to rotate password on checkin.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
request_id: Request ID
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
Response from rotation flag update
|
|
421
|
+
"""
|
|
422
|
+
return self.put(f"/Requests/{request_id}/RotateOnCheckin")
|
|
423
|
+
|
|
424
|
+
def approve_request(
|
|
425
|
+
self: "PasswordSafeClient",
|
|
426
|
+
request_id: int,
|
|
427
|
+
reason: Optional[str] = None,
|
|
428
|
+
) -> dict[str, Any]:
|
|
429
|
+
"""Approve a pending request.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
request_id: Request ID to approve
|
|
433
|
+
reason: Optional approval reason
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Response from approval
|
|
437
|
+
"""
|
|
438
|
+
data = {}
|
|
439
|
+
if reason:
|
|
440
|
+
data["Reason"] = reason
|
|
441
|
+
|
|
442
|
+
return self.post(f"/Requests/{request_id}/Approve", json=data if data else None)
|
|
443
|
+
|
|
444
|
+
def deny_request(
|
|
445
|
+
self: "PasswordSafeClient",
|
|
446
|
+
request_id: int,
|
|
447
|
+
reason: str,
|
|
448
|
+
) -> dict[str, Any]:
|
|
449
|
+
"""Deny a pending request.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
request_id: Request ID to deny
|
|
453
|
+
reason: Reason for denial (required)
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
Response from denial
|
|
457
|
+
"""
|
|
458
|
+
return self.post(f"/Requests/{request_id}/Deny", json={"Reason": reason})
|
|
459
|
+
|
|
460
|
+
# =========================================================================
|
|
461
|
+
# Sessions
|
|
462
|
+
# =========================================================================
|
|
463
|
+
|
|
464
|
+
def list_sessions(
|
|
465
|
+
self: "PasswordSafeClient",
|
|
466
|
+
system_id: Optional[int] = None,
|
|
467
|
+
status: Optional[str] = None,
|
|
468
|
+
) -> list[dict[str, Any]]:
|
|
469
|
+
"""List active sessions.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
system_id: Filter by system ID
|
|
473
|
+
status: Filter by status
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
List of session objects
|
|
477
|
+
"""
|
|
478
|
+
params = {}
|
|
479
|
+
if system_id:
|
|
480
|
+
params["systemId"] = system_id
|
|
481
|
+
if status:
|
|
482
|
+
params["status"] = status
|
|
483
|
+
|
|
484
|
+
return self.paginate("/Sessions", params=params)
|
|
485
|
+
|
|
486
|
+
def get_session(self: "PasswordSafeClient", session_id: int) -> dict[str, Any]:
|
|
487
|
+
"""Get a session by ID.
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
session_id: Session ID
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Session object
|
|
494
|
+
"""
|
|
495
|
+
return self.get(f"/Sessions/{session_id}")
|
|
496
|
+
|
|
497
|
+
def lock_session(self: "PasswordSafeClient", session_id: int) -> dict[str, Any]:
|
|
498
|
+
"""Lock an active session.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
session_id: Session ID to lock
|
|
502
|
+
|
|
503
|
+
Returns:
|
|
504
|
+
Response from lock operation
|
|
505
|
+
"""
|
|
506
|
+
return self.post(f"/Sessions/{session_id}/Lock")
|
|
507
|
+
|
|
508
|
+
def terminate_session(self: "PasswordSafeClient", session_id: int) -> dict[str, Any]:
|
|
509
|
+
"""Terminate an active session.
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
session_id: Session ID to terminate
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
Response from termination
|
|
516
|
+
"""
|
|
517
|
+
return self.delete(f"/Sessions/{session_id}")
|
|
518
|
+
|
|
519
|
+
# =========================================================================
|
|
520
|
+
# Applications
|
|
521
|
+
# =========================================================================
|
|
522
|
+
|
|
523
|
+
def list_applications(self: "PasswordSafeClient") -> list[dict[str, Any]]:
|
|
524
|
+
"""List all applications.
|
|
525
|
+
|
|
526
|
+
Returns:
|
|
527
|
+
List of application objects
|
|
528
|
+
"""
|
|
529
|
+
return self.paginate("/Applications")
|
|
530
|
+
|
|
531
|
+
def get_application(self: "PasswordSafeClient", app_id: int) -> dict[str, Any]:
|
|
532
|
+
"""Get an application by ID.
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
app_id: Application ID
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Application object
|
|
539
|
+
"""
|
|
540
|
+
return self.get(f"/Applications/{app_id}")
|
|
541
|
+
|
|
542
|
+
def get_account_applications(
|
|
543
|
+
self: "PasswordSafeClient", account_id: int
|
|
544
|
+
) -> list[dict[str, Any]]:
|
|
545
|
+
"""Get applications assigned to an account.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
account_id: Managed account ID
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
List of application objects
|
|
552
|
+
"""
|
|
553
|
+
return self.paginate(f"/ManagedAccounts/{account_id}/Applications")
|
|
554
|
+
|
|
555
|
+
def assign_application(
|
|
556
|
+
self: "PasswordSafeClient",
|
|
557
|
+
account_id: int,
|
|
558
|
+
app_id: int,
|
|
559
|
+
) -> dict[str, Any]:
|
|
560
|
+
"""Assign an application to an account.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
account_id: Managed account ID
|
|
564
|
+
app_id: Application ID
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
Response from assignment
|
|
568
|
+
"""
|
|
569
|
+
return self.post(f"/ManagedAccounts/{account_id}/Applications/{app_id}")
|
|
570
|
+
|
|
571
|
+
def unassign_application(
|
|
572
|
+
self: "PasswordSafeClient",
|
|
573
|
+
account_id: int,
|
|
574
|
+
app_id: int,
|
|
575
|
+
) -> dict[str, Any]:
|
|
576
|
+
"""Unassign an application from an account.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
account_id: Managed account ID
|
|
580
|
+
app_id: Application ID
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
Response from unassignment
|
|
584
|
+
"""
|
|
585
|
+
return self.delete(f"/ManagedAccounts/{account_id}/Applications/{app_id}")
|
|
586
|
+
|
|
587
|
+
# =========================================================================
|
|
588
|
+
# Functional Accounts
|
|
589
|
+
# =========================================================================
|
|
590
|
+
|
|
591
|
+
def list_functional_accounts(
|
|
592
|
+
self: "PasswordSafeClient",
|
|
593
|
+
search: Optional[str] = None,
|
|
594
|
+
) -> list[dict[str, Any]]:
|
|
595
|
+
"""List functional accounts.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
search: Optional search filter
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
List of functional account objects
|
|
602
|
+
"""
|
|
603
|
+
params = {}
|
|
604
|
+
if search:
|
|
605
|
+
params["search"] = search
|
|
606
|
+
|
|
607
|
+
return self.paginate("/FunctionalAccounts", params=params)
|
|
608
|
+
|
|
609
|
+
def get_functional_account(
|
|
610
|
+
self: "PasswordSafeClient", account_id: int
|
|
611
|
+
) -> dict[str, Any]:
|
|
612
|
+
"""Get a functional account by ID.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
account_id: Functional account ID
|
|
616
|
+
|
|
617
|
+
Returns:
|
|
618
|
+
Functional account object
|
|
619
|
+
"""
|
|
620
|
+
return self.get(f"/FunctionalAccounts/{account_id}")
|
|
621
|
+
|
|
622
|
+
def create_functional_account(
|
|
623
|
+
self: "PasswordSafeClient",
|
|
624
|
+
account_name: str,
|
|
625
|
+
platform_id: int,
|
|
626
|
+
display_name: Optional[str] = None,
|
|
627
|
+
description: Optional[str] = None,
|
|
628
|
+
domain_name: Optional[str] = None,
|
|
629
|
+
elevation_command: Optional[str] = None,
|
|
630
|
+
password: Optional[str] = None,
|
|
631
|
+
private_key: Optional[str] = None,
|
|
632
|
+
passphrase: Optional[str] = None,
|
|
633
|
+
# Entra ID specific fields
|
|
634
|
+
application_id: Optional[str] = None,
|
|
635
|
+
tenant_id: Optional[str] = None,
|
|
636
|
+
object_id: Optional[str] = None,
|
|
637
|
+
secret: Optional[str] = None,
|
|
638
|
+
# AWS specific fields
|
|
639
|
+
api_key: Optional[str] = None,
|
|
640
|
+
) -> dict[str, Any]:
|
|
641
|
+
"""Create a new functional account.
|
|
642
|
+
|
|
643
|
+
Args:
|
|
644
|
+
account_name: Account username (UPN format for Entra ID, IAM user for AWS)
|
|
645
|
+
platform_id: Platform ID (1=Windows, 2=Linux, 47=AWS, 84=Entra ID, etc.)
|
|
646
|
+
display_name: Display name for the account
|
|
647
|
+
description: Account description
|
|
648
|
+
domain_name: Domain name (for domain accounts)
|
|
649
|
+
elevation_command: Elevation command (sudo, pbrun, pmrun)
|
|
650
|
+
password: Account password (for password auth)
|
|
651
|
+
private_key: SSH private key content (for key auth)
|
|
652
|
+
passphrase: Passphrase for encrypted private key
|
|
653
|
+
application_id: Azure Application (Client) ID (Entra ID only)
|
|
654
|
+
tenant_id: Azure Tenant ID (Entra ID only)
|
|
655
|
+
object_id: Azure Object ID (Entra ID only)
|
|
656
|
+
secret: Azure Client Secret (Entra ID) or AWS Secret Access Key (AWS)
|
|
657
|
+
api_key: AWS Access Key ID (AWS only)
|
|
658
|
+
|
|
659
|
+
Returns:
|
|
660
|
+
Created functional account object
|
|
661
|
+
"""
|
|
662
|
+
data: dict[str, Any] = {
|
|
663
|
+
"AccountName": account_name,
|
|
664
|
+
"PlatformID": platform_id,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if display_name:
|
|
668
|
+
data["DisplayName"] = display_name
|
|
669
|
+
if description:
|
|
670
|
+
data["Description"] = description
|
|
671
|
+
if domain_name:
|
|
672
|
+
data["DomainName"] = domain_name
|
|
673
|
+
if elevation_command:
|
|
674
|
+
data["ElevationCommand"] = elevation_command
|
|
675
|
+
if password:
|
|
676
|
+
data["Password"] = password
|
|
677
|
+
if private_key:
|
|
678
|
+
data["PrivateKey"] = private_key
|
|
679
|
+
if passphrase:
|
|
680
|
+
data["Passphrase"] = passphrase
|
|
681
|
+
# Entra ID specific fields
|
|
682
|
+
if application_id:
|
|
683
|
+
data["ApplicationID"] = application_id
|
|
684
|
+
if tenant_id:
|
|
685
|
+
data["TenantID"] = tenant_id
|
|
686
|
+
if object_id:
|
|
687
|
+
data["ObjectID"] = object_id
|
|
688
|
+
if secret:
|
|
689
|
+
data["Secret"] = secret
|
|
690
|
+
# AWS specific fields
|
|
691
|
+
if api_key:
|
|
692
|
+
data["APIKey"] = api_key
|
|
693
|
+
|
|
694
|
+
return self.post("/FunctionalAccounts", json=data)
|
|
695
|
+
|
|
696
|
+
def update_functional_account(
|
|
697
|
+
self: "PasswordSafeClient",
|
|
698
|
+
account_id: int,
|
|
699
|
+
**kwargs: Any,
|
|
700
|
+
) -> dict[str, Any]:
|
|
701
|
+
"""Update a functional account.
|
|
702
|
+
|
|
703
|
+
Args:
|
|
704
|
+
account_id: Functional account ID
|
|
705
|
+
**kwargs: Fields to update
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
Updated functional account object
|
|
709
|
+
"""
|
|
710
|
+
data = {}
|
|
711
|
+
field_map = {
|
|
712
|
+
"account_name": "AccountName",
|
|
713
|
+
"display_name": "DisplayName",
|
|
714
|
+
"description": "Description",
|
|
715
|
+
"domain_name": "DomainName",
|
|
716
|
+
"elevation_command": "ElevationCommand",
|
|
717
|
+
"password": "Password",
|
|
718
|
+
"private_key": "PrivateKey",
|
|
719
|
+
"passphrase": "Passphrase",
|
|
720
|
+
}
|
|
721
|
+
for key, value in kwargs.items():
|
|
722
|
+
if value is not None and key in field_map:
|
|
723
|
+
data[field_map[key]] = value
|
|
724
|
+
|
|
725
|
+
return self.put(f"/FunctionalAccounts/{account_id}", json=data)
|
|
726
|
+
|
|
727
|
+
def delete_functional_account(
|
|
728
|
+
self: "PasswordSafeClient", account_id: int
|
|
729
|
+
) -> dict[str, Any]:
|
|
730
|
+
"""Delete a functional account.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
account_id: Functional account ID
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
Empty response on success
|
|
737
|
+
"""
|
|
738
|
+
return self.delete(f"/FunctionalAccounts/{account_id}")
|
|
739
|
+
|
|
740
|
+
# =========================================================================
|
|
741
|
+
# Password Rules
|
|
742
|
+
# =========================================================================
|
|
743
|
+
|
|
744
|
+
def list_password_rules(self: "PasswordSafeClient") -> list[dict[str, Any]]:
|
|
745
|
+
"""List password rules.
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
List of password rule objects
|
|
749
|
+
"""
|
|
750
|
+
return self.paginate("/PasswordRules")
|
|
751
|
+
|
|
752
|
+
def get_password_rule(self: "PasswordSafeClient", rule_id: int) -> dict[str, Any]:
|
|
753
|
+
"""Get a password rule by ID.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
rule_id: Password rule ID
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
Password rule object
|
|
760
|
+
"""
|
|
761
|
+
return self.get(f"/PasswordRules/{rule_id}")
|
|
762
|
+
|
|
763
|
+
# =========================================================================
|
|
764
|
+
# Access Policies
|
|
765
|
+
# =========================================================================
|
|
766
|
+
|
|
767
|
+
def list_access_policies(self: "PasswordSafeClient") -> list[dict[str, Any]]:
|
|
768
|
+
"""List Password Safe access policies.
|
|
769
|
+
|
|
770
|
+
Returns:
|
|
771
|
+
List of access policy objects
|
|
772
|
+
"""
|
|
773
|
+
return self.paginate("/AccessPolicies")
|
|
774
|
+
|
|
775
|
+
def get_access_policy(self: "PasswordSafeClient", policy_id: int) -> dict[str, Any]:
|
|
776
|
+
"""Get an access policy by ID.
|
|
777
|
+
|
|
778
|
+
Args:
|
|
779
|
+
policy_id: Access policy ID
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Access policy object
|
|
783
|
+
"""
|
|
784
|
+
return self.get(f"/AccessPolicies/{policy_id}")
|
|
785
|
+
|
|
786
|
+
def get_access_policy_assignees(
|
|
787
|
+
self: "PasswordSafeClient", policy_id: int
|
|
788
|
+
) -> list[dict[str, Any]]:
|
|
789
|
+
"""Get assignees for an access policy.
|
|
790
|
+
|
|
791
|
+
Args:
|
|
792
|
+
policy_id: Access policy ID
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
List of assignee objects (users/groups)
|
|
796
|
+
"""
|
|
797
|
+
return self.paginate(f"/AccessPolicies/{policy_id}/Assignees")
|
|
798
|
+
|
|
799
|
+
# =========================================================================
|
|
800
|
+
# User Groups
|
|
801
|
+
# =========================================================================
|
|
802
|
+
|
|
803
|
+
def list_user_groups(
|
|
804
|
+
self: "PasswordSafeClient",
|
|
805
|
+
search: Optional[str] = None,
|
|
806
|
+
) -> list[dict[str, Any]]:
|
|
807
|
+
"""List user groups.
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
search: Optional name filter
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
List of user group objects
|
|
814
|
+
"""
|
|
815
|
+
params = {}
|
|
816
|
+
if search:
|
|
817
|
+
params["name"] = search
|
|
818
|
+
return self.paginate("/UserGroups", params=params)
|
|
819
|
+
|
|
820
|
+
def get_user_group(self: "PasswordSafeClient", group_id: int) -> dict[str, Any]:
|
|
821
|
+
"""Get a user group by ID.
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
group_id: User group ID
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
User group object
|
|
828
|
+
"""
|
|
829
|
+
return self.get(f"/UserGroups/{group_id}")
|
|
830
|
+
|
|
831
|
+
def get_user_group_members(
|
|
832
|
+
self: "PasswordSafeClient", group_id: int
|
|
833
|
+
) -> list[dict[str, Any]]:
|
|
834
|
+
"""Get users in a user group.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
group_id: User group ID
|
|
838
|
+
|
|
839
|
+
Returns:
|
|
840
|
+
List of user objects (includes ClientID, AccessPolicyID for API users)
|
|
841
|
+
"""
|
|
842
|
+
return self.paginate(f"/UserGroups/{group_id}/Users")
|
|
843
|
+
|
|
844
|
+
# =========================================================================
|
|
845
|
+
# Users
|
|
846
|
+
# =========================================================================
|
|
847
|
+
|
|
848
|
+
def list_users(
|
|
849
|
+
self: "PasswordSafeClient",
|
|
850
|
+
search: Optional[str] = None,
|
|
851
|
+
limit: Optional[int] = None,
|
|
852
|
+
) -> list[dict[str, Any]]:
|
|
853
|
+
"""List users.
|
|
854
|
+
|
|
855
|
+
Args:
|
|
856
|
+
search: Optional username search filter (client-side filtering)
|
|
857
|
+
limit: Maximum number of results (None for all)
|
|
858
|
+
|
|
859
|
+
Returns:
|
|
860
|
+
List of user objects
|
|
861
|
+
"""
|
|
862
|
+
# API doesn't support server-side search, so we fetch all and filter client-side
|
|
863
|
+
users = self.paginate("/Users")
|
|
864
|
+
|
|
865
|
+
# Apply client-side search filter
|
|
866
|
+
if search:
|
|
867
|
+
search_lower = search.lower()
|
|
868
|
+
users = [
|
|
869
|
+
u for u in users
|
|
870
|
+
if search_lower in (u.get("UserName", "") or "").lower()
|
|
871
|
+
or search_lower in (u.get("FirstName", "") or "").lower()
|
|
872
|
+
or search_lower in (u.get("LastName", "") or "").lower()
|
|
873
|
+
or search_lower in (u.get("EmailAddress", "") or "").lower()
|
|
874
|
+
]
|
|
875
|
+
|
|
876
|
+
# Apply limit
|
|
877
|
+
if limit is not None:
|
|
878
|
+
users = users[:limit]
|
|
879
|
+
|
|
880
|
+
return users
|
|
881
|
+
|
|
882
|
+
def get_user(self: "PasswordSafeClient", user_id: int) -> dict[str, Any]:
|
|
883
|
+
"""Get a user by ID.
|
|
884
|
+
|
|
885
|
+
Args:
|
|
886
|
+
user_id: User ID
|
|
887
|
+
|
|
888
|
+
Returns:
|
|
889
|
+
User object
|
|
890
|
+
"""
|
|
891
|
+
return self.get(f"/Users/{user_id}")
|
|
892
|
+
|
|
893
|
+
# =========================================================================
|
|
894
|
+
# Roles
|
|
895
|
+
# =========================================================================
|
|
896
|
+
|
|
897
|
+
def list_roles(self: "PasswordSafeClient") -> list[dict[str, Any]]:
|
|
898
|
+
"""List available roles (Requestor, Approver, etc).
|
|
899
|
+
|
|
900
|
+
Returns:
|
|
901
|
+
List of role objects with RoleID and Name
|
|
902
|
+
"""
|
|
903
|
+
result = self.get("/Roles")
|
|
904
|
+
if isinstance(result, list):
|
|
905
|
+
return result
|
|
906
|
+
if isinstance(result, dict):
|
|
907
|
+
return result.get("Data", result.get("results", []))
|
|
908
|
+
return []
|
|
909
|
+
|
|
910
|
+
# =========================================================================
|
|
911
|
+
# Secrets Safe - Safes
|
|
912
|
+
# =========================================================================
|
|
913
|
+
|
|
914
|
+
def list_safes(
|
|
915
|
+
self: "PasswordSafeClient",
|
|
916
|
+
) -> list[dict[str, Any]]:
|
|
917
|
+
"""List all Secrets Safe safes accessible to current user.
|
|
918
|
+
|
|
919
|
+
Requires: Secrets-Safe (Read) permission.
|
|
920
|
+
|
|
921
|
+
Returns:
|
|
922
|
+
List of safe objects with Id, Name, Description, etc.
|
|
923
|
+
"""
|
|
924
|
+
result = self.get("/Secrets-Safe/Safes")
|
|
925
|
+
if isinstance(result, list):
|
|
926
|
+
return result
|
|
927
|
+
if isinstance(result, dict) and "Data" in result:
|
|
928
|
+
return result["Data"]
|
|
929
|
+
return []
|
|
930
|
+
|
|
931
|
+
def get_safe(self: "PasswordSafeClient", safe_id: str) -> dict[str, Any]:
|
|
932
|
+
"""Get a safe by ID.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
safe_id: Safe ID (GUID)
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
Safe object
|
|
939
|
+
"""
|
|
940
|
+
return self.get(f"/Secrets-Safe/Safes/{safe_id}")
|
|
941
|
+
|
|
942
|
+
def create_safe(
|
|
943
|
+
self: "PasswordSafeClient",
|
|
944
|
+
name: str,
|
|
945
|
+
description: Optional[str] = None,
|
|
946
|
+
) -> dict[str, Any]:
|
|
947
|
+
"""Create a new Secrets Safe safe.
|
|
948
|
+
|
|
949
|
+
Requires: Secrets-Safe (Read/Write) permission.
|
|
950
|
+
|
|
951
|
+
Args:
|
|
952
|
+
name: Safe name
|
|
953
|
+
description: Safe description
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
Created safe object
|
|
957
|
+
"""
|
|
958
|
+
data: dict[str, Any] = {"Name": name}
|
|
959
|
+
if description:
|
|
960
|
+
data["Description"] = description
|
|
961
|
+
|
|
962
|
+
return self.post("/Secrets-Safe/Safes", json=data)
|
|
963
|
+
|
|
964
|
+
def update_safe(
|
|
965
|
+
self: "PasswordSafeClient",
|
|
966
|
+
safe_id: str,
|
|
967
|
+
name: Optional[str] = None,
|
|
968
|
+
description: Optional[str] = None,
|
|
969
|
+
) -> dict[str, Any]:
|
|
970
|
+
"""Update a Secrets Safe safe.
|
|
971
|
+
|
|
972
|
+
Args:
|
|
973
|
+
safe_id: Safe ID (GUID)
|
|
974
|
+
name: New safe name
|
|
975
|
+
description: New description
|
|
976
|
+
|
|
977
|
+
Returns:
|
|
978
|
+
Updated safe object
|
|
979
|
+
"""
|
|
980
|
+
data: dict[str, Any] = {}
|
|
981
|
+
if name:
|
|
982
|
+
data["Name"] = name
|
|
983
|
+
if description is not None:
|
|
984
|
+
data["Description"] = description
|
|
985
|
+
|
|
986
|
+
return self.put(f"/Secrets-Safe/Safes/{safe_id}", json=data)
|
|
987
|
+
|
|
988
|
+
def delete_safe(self: "PasswordSafeClient", safe_id: str) -> dict[str, Any]:
|
|
989
|
+
"""Delete a Secrets Safe safe.
|
|
990
|
+
|
|
991
|
+
Note: Safe must be empty (no folders or secrets) to delete.
|
|
992
|
+
|
|
993
|
+
Args:
|
|
994
|
+
safe_id: Safe ID (GUID) to delete
|
|
995
|
+
|
|
996
|
+
Returns:
|
|
997
|
+
Empty response on success
|
|
998
|
+
"""
|
|
999
|
+
return self.delete(f"/Secrets-Safe/Safes/{safe_id}")
|
|
1000
|
+
|
|
1001
|
+
def list_safe_permissions_options(
|
|
1002
|
+
self: "PasswordSafeClient",
|
|
1003
|
+
) -> list[str]:
|
|
1004
|
+
"""Get all possible safe permission flags.
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
List of permission flag strings (e.g., "Read", "Write", "Manage")
|
|
1008
|
+
"""
|
|
1009
|
+
result = self.get("/Secrets-Safe/Safes/Safe-Permissions")
|
|
1010
|
+
if isinstance(result, list):
|
|
1011
|
+
return result
|
|
1012
|
+
return []
|
|
1013
|
+
|
|
1014
|
+
def get_safe_permissions(
|
|
1015
|
+
self: "PasswordSafeClient", safe_id: str
|
|
1016
|
+
) -> list[dict[str, Any]]:
|
|
1017
|
+
"""Get permissions assigned to a safe.
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
safe_id: Safe ID (GUID)
|
|
1021
|
+
|
|
1022
|
+
Returns:
|
|
1023
|
+
List of permission objects with Id, FolderId, GroupId, UserId,
|
|
1024
|
+
PermissionFlags, ExpiresOn
|
|
1025
|
+
"""
|
|
1026
|
+
result = self.get(f"/Secrets-Safe/Safes/{safe_id}/Safe-Permissions")
|
|
1027
|
+
if isinstance(result, list):
|
|
1028
|
+
return result
|
|
1029
|
+
if isinstance(result, dict) and "Data" in result:
|
|
1030
|
+
return result["Data"]
|
|
1031
|
+
return []
|
|
1032
|
+
|
|
1033
|
+
def update_safe_permissions(
|
|
1034
|
+
self: "PasswordSafeClient",
|
|
1035
|
+
safe_id: str,
|
|
1036
|
+
principal_type: int,
|
|
1037
|
+
principal_id: int,
|
|
1038
|
+
permission_flags: list[str],
|
|
1039
|
+
expires_on: Optional[str] = None,
|
|
1040
|
+
) -> dict[str, Any]:
|
|
1041
|
+
"""Assign or update permissions for a user or group on a safe.
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
safe_id: Safe ID (GUID)
|
|
1045
|
+
principal_type: 0 for user, 1 for group
|
|
1046
|
+
principal_id: ID of the user or group
|
|
1047
|
+
permission_flags: List of permission flags (e.g., ["Read", "Write"])
|
|
1048
|
+
expires_on: Optional expiry datetime
|
|
1049
|
+
|
|
1050
|
+
Returns:
|
|
1051
|
+
Empty response on success (204)
|
|
1052
|
+
"""
|
|
1053
|
+
data: dict[str, Any] = {
|
|
1054
|
+
"PrincipalType": principal_type,
|
|
1055
|
+
"PrincipalID": principal_id,
|
|
1056
|
+
"PermissionFlags": permission_flags,
|
|
1057
|
+
}
|
|
1058
|
+
if expires_on:
|
|
1059
|
+
data["ExpiresOn"] = expires_on
|
|
1060
|
+
|
|
1061
|
+
return self.put(f"/Secrets-Safe/Safes/{safe_id}/Safe-Permissions", json=data)
|
|
1062
|
+
|
|
1063
|
+
def grant_safe_permission_to_user(
|
|
1064
|
+
self: "PasswordSafeClient",
|
|
1065
|
+
safe_id: str,
|
|
1066
|
+
user_id: int,
|
|
1067
|
+
permission_flags: list[str],
|
|
1068
|
+
expires_on: Optional[str] = None,
|
|
1069
|
+
) -> dict[str, Any]:
|
|
1070
|
+
"""Grant permissions to a user on a safe.
|
|
1071
|
+
|
|
1072
|
+
Args:
|
|
1073
|
+
safe_id: Safe ID (GUID)
|
|
1074
|
+
user_id: User ID
|
|
1075
|
+
permission_flags: List of permission flags
|
|
1076
|
+
expires_on: Optional expiry datetime
|
|
1077
|
+
|
|
1078
|
+
Returns:
|
|
1079
|
+
Empty response on success
|
|
1080
|
+
"""
|
|
1081
|
+
return self.update_safe_permissions(
|
|
1082
|
+
safe_id, principal_type=0, principal_id=user_id,
|
|
1083
|
+
permission_flags=permission_flags, expires_on=expires_on
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
def grant_safe_permission_to_group(
|
|
1087
|
+
self: "PasswordSafeClient",
|
|
1088
|
+
safe_id: str,
|
|
1089
|
+
group_id: int,
|
|
1090
|
+
permission_flags: list[str],
|
|
1091
|
+
expires_on: Optional[str] = None,
|
|
1092
|
+
) -> dict[str, Any]:
|
|
1093
|
+
"""Grant permissions to a group on a safe.
|
|
1094
|
+
|
|
1095
|
+
Args:
|
|
1096
|
+
safe_id: Safe ID (GUID)
|
|
1097
|
+
group_id: Group ID
|
|
1098
|
+
permission_flags: List of permission flags
|
|
1099
|
+
expires_on: Optional expiry datetime
|
|
1100
|
+
|
|
1101
|
+
Returns:
|
|
1102
|
+
Empty response on success
|
|
1103
|
+
"""
|
|
1104
|
+
return self.update_safe_permissions(
|
|
1105
|
+
safe_id, principal_type=1, principal_id=group_id,
|
|
1106
|
+
permission_flags=permission_flags, expires_on=expires_on
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
# =========================================================================
|
|
1110
|
+
# Secrets Safe - Folders
|
|
1111
|
+
# =========================================================================
|
|
1112
|
+
|
|
1113
|
+
def list_folders(
|
|
1114
|
+
self: "PasswordSafeClient",
|
|
1115
|
+
folder_name: Optional[str] = None,
|
|
1116
|
+
folder_path: Optional[str] = None,
|
|
1117
|
+
include_subfolders: bool = True,
|
|
1118
|
+
root_only: bool = False,
|
|
1119
|
+
limit: Optional[int] = None,
|
|
1120
|
+
) -> list[dict[str, Any]]:
|
|
1121
|
+
"""List Secrets Safe folders.
|
|
1122
|
+
|
|
1123
|
+
Requires: Secrets-Safe (Read) permission.
|
|
1124
|
+
|
|
1125
|
+
Args:
|
|
1126
|
+
folder_name: Filter by partial folder name
|
|
1127
|
+
folder_path: Filter by path (uses '/' separator)
|
|
1128
|
+
include_subfolders: Include subfolders (default: true)
|
|
1129
|
+
root_only: Only return root-level folders
|
|
1130
|
+
limit: Maximum number of results (default: 1000)
|
|
1131
|
+
|
|
1132
|
+
Returns:
|
|
1133
|
+
List of folder objects
|
|
1134
|
+
"""
|
|
1135
|
+
params: dict[str, Any] = {}
|
|
1136
|
+
if folder_name:
|
|
1137
|
+
params["FolderName"] = folder_name
|
|
1138
|
+
if folder_path:
|
|
1139
|
+
params["FolderPath"] = folder_path
|
|
1140
|
+
if not include_subfolders:
|
|
1141
|
+
params["IncludeSubfolders"] = False
|
|
1142
|
+
if root_only:
|
|
1143
|
+
params["RootOnly"] = True
|
|
1144
|
+
if limit:
|
|
1145
|
+
params["Limit"] = limit
|
|
1146
|
+
|
|
1147
|
+
result = self.get("/Secrets-Safe/Folders", params=params)
|
|
1148
|
+
if isinstance(result, list):
|
|
1149
|
+
return result
|
|
1150
|
+
if isinstance(result, dict) and "Data" in result:
|
|
1151
|
+
return result["Data"]
|
|
1152
|
+
return []
|
|
1153
|
+
|
|
1154
|
+
def get_folder(self: "PasswordSafeClient", folder_id: str) -> dict[str, Any]:
|
|
1155
|
+
"""Get a folder by ID.
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
folder_id: Folder ID (GUID)
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
Folder object
|
|
1162
|
+
"""
|
|
1163
|
+
# API doesn't have a direct get-by-id, filter by ID from list
|
|
1164
|
+
folders = self.list_folders()
|
|
1165
|
+
for folder in folders:
|
|
1166
|
+
if folder.get("Id") == folder_id:
|
|
1167
|
+
return folder
|
|
1168
|
+
raise ValueError(f"Folder not found: {folder_id}")
|
|
1169
|
+
|
|
1170
|
+
def get_folder_by_path(
|
|
1171
|
+
self: "PasswordSafeClient", folder_path: str
|
|
1172
|
+
) -> Optional[dict[str, Any]]:
|
|
1173
|
+
"""Get a folder by its path.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
folder_path: Full folder path (e.g., "Parent/Child")
|
|
1177
|
+
|
|
1178
|
+
Returns:
|
|
1179
|
+
Folder object or None if not found
|
|
1180
|
+
"""
|
|
1181
|
+
folders = self.list_folders(folder_path=folder_path)
|
|
1182
|
+
for folder in folders:
|
|
1183
|
+
if folder.get("FolderPath") == folder_path or folder.get("Name") == folder_path:
|
|
1184
|
+
return folder
|
|
1185
|
+
return None
|
|
1186
|
+
|
|
1187
|
+
def create_folder(
|
|
1188
|
+
self: "PasswordSafeClient",
|
|
1189
|
+
name: str,
|
|
1190
|
+
parent_id: str,
|
|
1191
|
+
description: Optional[str] = None,
|
|
1192
|
+
) -> dict[str, Any]:
|
|
1193
|
+
"""Create a new Secrets Safe folder.
|
|
1194
|
+
|
|
1195
|
+
Requires: Secrets-Safe (Read/Write) permission.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
name: Folder name
|
|
1199
|
+
parent_id: Parent ID (GUID) - Safe ID for root folder, or Folder ID for subfolder
|
|
1200
|
+
description: Folder description
|
|
1201
|
+
|
|
1202
|
+
Returns:
|
|
1203
|
+
Created folder object
|
|
1204
|
+
"""
|
|
1205
|
+
data: dict[str, Any] = {
|
|
1206
|
+
"Name": name,
|
|
1207
|
+
"ParentId": parent_id,
|
|
1208
|
+
}
|
|
1209
|
+
if description:
|
|
1210
|
+
data["Description"] = description
|
|
1211
|
+
|
|
1212
|
+
return self.post("/Secrets-Safe/Folders", json=data)
|
|
1213
|
+
|
|
1214
|
+
def update_folder(
|
|
1215
|
+
self: "PasswordSafeClient",
|
|
1216
|
+
folder_id: str,
|
|
1217
|
+
name: Optional[str] = None,
|
|
1218
|
+
description: Optional[str] = None,
|
|
1219
|
+
) -> dict[str, Any]:
|
|
1220
|
+
"""Update a Secrets Safe folder.
|
|
1221
|
+
|
|
1222
|
+
Args:
|
|
1223
|
+
folder_id: Folder ID (GUID)
|
|
1224
|
+
name: New folder name
|
|
1225
|
+
description: New description
|
|
1226
|
+
|
|
1227
|
+
Returns:
|
|
1228
|
+
Updated folder object
|
|
1229
|
+
"""
|
|
1230
|
+
data: dict[str, Any] = {}
|
|
1231
|
+
if name:
|
|
1232
|
+
data["Name"] = name
|
|
1233
|
+
if description is not None:
|
|
1234
|
+
data["Description"] = description
|
|
1235
|
+
|
|
1236
|
+
return self.put(f"/Secrets-Safe/Folders/{folder_id}", json=data)
|
|
1237
|
+
|
|
1238
|
+
def delete_folder(self: "PasswordSafeClient", folder_id: str) -> dict[str, Any]:
|
|
1239
|
+
"""Delete a Secrets Safe folder.
|
|
1240
|
+
|
|
1241
|
+
Note: Folders containing secrets cannot be deleted.
|
|
1242
|
+
|
|
1243
|
+
Args:
|
|
1244
|
+
folder_id: Folder ID (GUID) to delete
|
|
1245
|
+
|
|
1246
|
+
Returns:
|
|
1247
|
+
Empty response on success
|
|
1248
|
+
"""
|
|
1249
|
+
return self.delete(f"/Secrets-Safe/Folders/{folder_id}")
|
|
1250
|
+
|
|
1251
|
+
# =========================================================================
|
|
1252
|
+
# Secrets Safe - Secrets
|
|
1253
|
+
# =========================================================================
|
|
1254
|
+
|
|
1255
|
+
def list_secrets(
|
|
1256
|
+
self: "PasswordSafeClient",
|
|
1257
|
+
folder_id: Optional[str] = None,
|
|
1258
|
+
title: Optional[str] = None,
|
|
1259
|
+
limit: Optional[int] = None,
|
|
1260
|
+
) -> list[dict[str, Any]]:
|
|
1261
|
+
"""List Secrets Safe secrets.
|
|
1262
|
+
|
|
1263
|
+
Requires: Secrets-Safe (Read) permission.
|
|
1264
|
+
|
|
1265
|
+
Args:
|
|
1266
|
+
folder_id: Filter by folder ID (GUID)
|
|
1267
|
+
title: Filter by secret title
|
|
1268
|
+
limit: Maximum number of results
|
|
1269
|
+
|
|
1270
|
+
Returns:
|
|
1271
|
+
List of secret objects (without password values)
|
|
1272
|
+
"""
|
|
1273
|
+
params: dict[str, Any] = {}
|
|
1274
|
+
if folder_id:
|
|
1275
|
+
params["folderId"] = folder_id
|
|
1276
|
+
if title:
|
|
1277
|
+
params["title"] = title
|
|
1278
|
+
if limit:
|
|
1279
|
+
params["Limit"] = limit
|
|
1280
|
+
|
|
1281
|
+
result = self.get("/Secrets-Safe/Secrets", params=params)
|
|
1282
|
+
if isinstance(result, list):
|
|
1283
|
+
return result
|
|
1284
|
+
if isinstance(result, dict) and "Data" in result:
|
|
1285
|
+
return result["Data"]
|
|
1286
|
+
return []
|
|
1287
|
+
|
|
1288
|
+
def get_secret(self: "PasswordSafeClient", secret_id: str) -> dict[str, Any]:
|
|
1289
|
+
"""Get a secret by ID (includes password value).
|
|
1290
|
+
|
|
1291
|
+
Args:
|
|
1292
|
+
secret_id: Secret ID (GUID)
|
|
1293
|
+
|
|
1294
|
+
Returns:
|
|
1295
|
+
Secret object with password in 'Password' field
|
|
1296
|
+
"""
|
|
1297
|
+
return self.get(f"/Secrets-Safe/Secrets/{secret_id}")
|
|
1298
|
+
|
|
1299
|
+
def get_secret_by_title(
|
|
1300
|
+
self: "PasswordSafeClient",
|
|
1301
|
+
title: str,
|
|
1302
|
+
folder_id: Optional[str] = None,
|
|
1303
|
+
) -> Optional[dict[str, Any]]:
|
|
1304
|
+
"""Get a secret by title.
|
|
1305
|
+
|
|
1306
|
+
Args:
|
|
1307
|
+
title: Secret title
|
|
1308
|
+
folder_id: Optional folder ID to search in
|
|
1309
|
+
|
|
1310
|
+
Returns:
|
|
1311
|
+
Secret object or None if not found
|
|
1312
|
+
"""
|
|
1313
|
+
secrets = self.list_secrets(folder_id=folder_id, title=title)
|
|
1314
|
+
for secret in secrets:
|
|
1315
|
+
if secret.get("Title") == title:
|
|
1316
|
+
return self.get_secret(secret["Id"])
|
|
1317
|
+
return None
|
|
1318
|
+
|
|
1319
|
+
def create_secret(
|
|
1320
|
+
self: "PasswordSafeClient",
|
|
1321
|
+
folder_id: str,
|
|
1322
|
+
title: str,
|
|
1323
|
+
username: str,
|
|
1324
|
+
password: str,
|
|
1325
|
+
description: Optional[str] = None,
|
|
1326
|
+
notes: Optional[str] = None,
|
|
1327
|
+
urls: Optional[list[str]] = None,
|
|
1328
|
+
owner_user_id: Optional[int] = None,
|
|
1329
|
+
owner_group_id: Optional[int] = None,
|
|
1330
|
+
) -> dict[str, Any]:
|
|
1331
|
+
"""Create a new secret in Secrets Safe.
|
|
1332
|
+
|
|
1333
|
+
Requires: Secrets-Safe (Read/Write) permission.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
folder_id: Folder ID (GUID) to create secret in
|
|
1337
|
+
title: Secret title
|
|
1338
|
+
username: Username/account name
|
|
1339
|
+
password: Password value (max 256 chars)
|
|
1340
|
+
description: Description (max 256 chars)
|
|
1341
|
+
notes: Additional notes
|
|
1342
|
+
urls: List of associated URLs
|
|
1343
|
+
owner_user_id: Owner user ID (defaults to current session user)
|
|
1344
|
+
owner_group_id: Owner group ID
|
|
1345
|
+
|
|
1346
|
+
Returns:
|
|
1347
|
+
Created secret object
|
|
1348
|
+
"""
|
|
1349
|
+
data: dict[str, Any] = {
|
|
1350
|
+
"Title": title,
|
|
1351
|
+
"Username": username,
|
|
1352
|
+
"Password": password,
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if description:
|
|
1356
|
+
data["Description"] = description
|
|
1357
|
+
if notes:
|
|
1358
|
+
data["Notes"] = notes
|
|
1359
|
+
if urls:
|
|
1360
|
+
data["Urls"] = [{"Url": url} for url in urls]
|
|
1361
|
+
|
|
1362
|
+
# API requires both v3.0 (OwnerId/OwnerType) and v3.1 (Owners array) formats
|
|
1363
|
+
# Default to current session user if no owner specified
|
|
1364
|
+
if not owner_user_id and not owner_group_id:
|
|
1365
|
+
# Get current user from session info
|
|
1366
|
+
session_info = self.post("/Auth/SignAppIn")
|
|
1367
|
+
owner_user_id = session_info.get("UserId")
|
|
1368
|
+
|
|
1369
|
+
if owner_group_id:
|
|
1370
|
+
data["OwnerId"] = owner_group_id
|
|
1371
|
+
data["OwnerType"] = "Group"
|
|
1372
|
+
data["Owners"] = [{"OwnerId": owner_group_id, "GroupId": owner_group_id}]
|
|
1373
|
+
elif owner_user_id:
|
|
1374
|
+
data["OwnerId"] = owner_user_id
|
|
1375
|
+
data["OwnerType"] = "User"
|
|
1376
|
+
data["Owners"] = [{"OwnerId": owner_user_id, "UserId": owner_user_id}]
|
|
1377
|
+
|
|
1378
|
+
return self.post(f"/Secrets-Safe/Folders/{folder_id}/Secrets", json=data)
|
|
1379
|
+
|
|
1380
|
+
def update_secret(
|
|
1381
|
+
self: "PasswordSafeClient",
|
|
1382
|
+
secret_id: str,
|
|
1383
|
+
title: Optional[str] = None,
|
|
1384
|
+
username: Optional[str] = None,
|
|
1385
|
+
password: Optional[str] = None,
|
|
1386
|
+
description: Optional[str] = None,
|
|
1387
|
+
notes: Optional[str] = None,
|
|
1388
|
+
urls: Optional[list[str]] = None,
|
|
1389
|
+
) -> dict[str, Any]:
|
|
1390
|
+
"""Update an existing secret.
|
|
1391
|
+
|
|
1392
|
+
Args:
|
|
1393
|
+
secret_id: Secret ID (GUID)
|
|
1394
|
+
title: New title
|
|
1395
|
+
username: New username
|
|
1396
|
+
password: New password
|
|
1397
|
+
description: New description
|
|
1398
|
+
notes: New notes
|
|
1399
|
+
urls: New list of URLs
|
|
1400
|
+
|
|
1401
|
+
Returns:
|
|
1402
|
+
Updated secret object
|
|
1403
|
+
"""
|
|
1404
|
+
# Get current secret - API requires ALL fields on update
|
|
1405
|
+
current = self.get_secret(secret_id)
|
|
1406
|
+
|
|
1407
|
+
# Build update payload with all required fields
|
|
1408
|
+
data: dict[str, Any] = {
|
|
1409
|
+
"Title": title or current.get("Title"),
|
|
1410
|
+
"Username": username or current.get("Username"),
|
|
1411
|
+
"Password": password or current.get("Password"),
|
|
1412
|
+
"FolderId": current.get("FolderId"),
|
|
1413
|
+
"OwnerId": current.get("OwnerId"),
|
|
1414
|
+
"OwnerType": current.get("OwnerType", "User"),
|
|
1415
|
+
"Owners": current.get("Owners", []),
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
# Optional fields - update if provided, otherwise preserve current
|
|
1419
|
+
if description is not None:
|
|
1420
|
+
data["Description"] = description
|
|
1421
|
+
elif current.get("Description"):
|
|
1422
|
+
data["Description"] = current["Description"]
|
|
1423
|
+
|
|
1424
|
+
if notes is not None:
|
|
1425
|
+
data["Notes"] = notes
|
|
1426
|
+
elif current.get("Notes"):
|
|
1427
|
+
data["Notes"] = current["Notes"]
|
|
1428
|
+
|
|
1429
|
+
if urls is not None:
|
|
1430
|
+
data["Urls"] = [{"Url": url} for url in urls]
|
|
1431
|
+
elif current.get("Urls"):
|
|
1432
|
+
data["Urls"] = current["Urls"]
|
|
1433
|
+
|
|
1434
|
+
return self.put(f"/Secrets-Safe/Secrets/{secret_id}", json=data)
|
|
1435
|
+
|
|
1436
|
+
def delete_secret(self: "PasswordSafeClient", secret_id: str) -> dict[str, Any]:
|
|
1437
|
+
"""Delete a secret.
|
|
1438
|
+
|
|
1439
|
+
Args:
|
|
1440
|
+
secret_id: Secret ID (GUID) to delete
|
|
1441
|
+
|
|
1442
|
+
Returns:
|
|
1443
|
+
Empty response on success
|
|
1444
|
+
"""
|
|
1445
|
+
return self.delete(f"/Secrets-Safe/Secrets/{secret_id}")
|
|
1446
|
+
|
|
1447
|
+
def create_text_secret(
|
|
1448
|
+
self: "PasswordSafeClient",
|
|
1449
|
+
folder_id: str,
|
|
1450
|
+
title: str,
|
|
1451
|
+
text: str,
|
|
1452
|
+
description: Optional[str] = None,
|
|
1453
|
+
notes: Optional[str] = None,
|
|
1454
|
+
owner_user_id: Optional[int] = None,
|
|
1455
|
+
owner_group_id: Optional[int] = None,
|
|
1456
|
+
) -> dict[str, Any]:
|
|
1457
|
+
"""Create a Text type secret in Secrets Safe.
|
|
1458
|
+
|
|
1459
|
+
Text secrets store arbitrary text content (JSON, config, etc).
|
|
1460
|
+
|
|
1461
|
+
Args:
|
|
1462
|
+
folder_id: Folder ID (GUID) to create secret in
|
|
1463
|
+
title: Secret title
|
|
1464
|
+
text: Text content (max 4096 chars)
|
|
1465
|
+
description: Description (max 256 chars)
|
|
1466
|
+
notes: Additional notes (max 4000 chars)
|
|
1467
|
+
owner_user_id: Owner user ID (defaults to current session user)
|
|
1468
|
+
owner_group_id: Owner group ID
|
|
1469
|
+
|
|
1470
|
+
Returns:
|
|
1471
|
+
Created secret object
|
|
1472
|
+
"""
|
|
1473
|
+
data: dict[str, Any] = {
|
|
1474
|
+
"Title": title,
|
|
1475
|
+
"Text": text,
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if description:
|
|
1479
|
+
data["Description"] = description
|
|
1480
|
+
if notes:
|
|
1481
|
+
data["Notes"] = notes
|
|
1482
|
+
|
|
1483
|
+
# API requires both v3.0 and v3.1 owner formats
|
|
1484
|
+
if not owner_user_id and not owner_group_id:
|
|
1485
|
+
session_info = self.post("/Auth/SignAppIn")
|
|
1486
|
+
owner_user_id = session_info.get("UserId")
|
|
1487
|
+
|
|
1488
|
+
if owner_group_id:
|
|
1489
|
+
data["OwnerId"] = owner_group_id
|
|
1490
|
+
data["OwnerType"] = "Group"
|
|
1491
|
+
data["Owners"] = [{"OwnerId": owner_group_id, "GroupId": owner_group_id}]
|
|
1492
|
+
elif owner_user_id:
|
|
1493
|
+
data["OwnerId"] = owner_user_id
|
|
1494
|
+
data["OwnerType"] = "User"
|
|
1495
|
+
data["Owners"] = [{"OwnerId": owner_user_id, "UserId": owner_user_id}]
|
|
1496
|
+
|
|
1497
|
+
return self.post(f"/Secrets-Safe/Folders/{folder_id}/Secrets/Text", json=data)
|
|
1498
|
+
|
|
1499
|
+
def create_file_secret(
|
|
1500
|
+
self: "PasswordSafeClient",
|
|
1501
|
+
folder_id: str,
|
|
1502
|
+
title: str,
|
|
1503
|
+
file_content: bytes,
|
|
1504
|
+
file_name: str,
|
|
1505
|
+
description: Optional[str] = None,
|
|
1506
|
+
notes: Optional[str] = None,
|
|
1507
|
+
owner_user_id: Optional[int] = None,
|
|
1508
|
+
owner_group_id: Optional[int] = None,
|
|
1509
|
+
) -> dict[str, Any]:
|
|
1510
|
+
"""Create a File type secret in Secrets Safe.
|
|
1511
|
+
|
|
1512
|
+
File secrets store binary file content (certificates, keys, etc).
|
|
1513
|
+
Max file size is 5 MB.
|
|
1514
|
+
|
|
1515
|
+
Args:
|
|
1516
|
+
folder_id: Folder ID (GUID) to create secret in
|
|
1517
|
+
title: Secret title
|
|
1518
|
+
file_content: File content as bytes
|
|
1519
|
+
file_name: Original filename
|
|
1520
|
+
description: Description (max 256 chars)
|
|
1521
|
+
notes: Additional notes (max 4000 chars)
|
|
1522
|
+
owner_user_id: Owner user ID (defaults to current session user)
|
|
1523
|
+
owner_group_id: Owner group ID
|
|
1524
|
+
|
|
1525
|
+
Returns:
|
|
1526
|
+
Created secret object
|
|
1527
|
+
"""
|
|
1528
|
+
import json as json_module
|
|
1529
|
+
|
|
1530
|
+
# Build metadata
|
|
1531
|
+
metadata: dict[str, Any] = {
|
|
1532
|
+
"Title": title,
|
|
1533
|
+
"FileName": file_name,
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
if description:
|
|
1537
|
+
metadata["Description"] = description
|
|
1538
|
+
if notes:
|
|
1539
|
+
metadata["Notes"] = notes
|
|
1540
|
+
|
|
1541
|
+
# API requires both v3.0 and v3.1 owner formats
|
|
1542
|
+
if not owner_user_id and not owner_group_id:
|
|
1543
|
+
session_info = self.post("/Auth/SignAppIn")
|
|
1544
|
+
owner_user_id = session_info.get("UserId")
|
|
1545
|
+
|
|
1546
|
+
if owner_group_id:
|
|
1547
|
+
metadata["OwnerId"] = owner_group_id
|
|
1548
|
+
metadata["OwnerType"] = "Group"
|
|
1549
|
+
metadata["Owners"] = [{"OwnerId": owner_group_id, "GroupId": owner_group_id}]
|
|
1550
|
+
elif owner_user_id:
|
|
1551
|
+
metadata["OwnerId"] = owner_user_id
|
|
1552
|
+
metadata["OwnerType"] = "User"
|
|
1553
|
+
metadata["Owners"] = [{"OwnerId": owner_user_id, "UserId": owner_user_id}]
|
|
1554
|
+
|
|
1555
|
+
# Multipart form data
|
|
1556
|
+
client = self._ensure_client()
|
|
1557
|
+
headers = self._get_auth_headers()
|
|
1558
|
+
headers.pop("Content-Type", None) # Let httpx set multipart content-type
|
|
1559
|
+
|
|
1560
|
+
files = {
|
|
1561
|
+
"secretmetadata": (None, json_module.dumps(metadata), "application/json"),
|
|
1562
|
+
"file": (file_name, file_content, "application/octet-stream"),
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
response = client.post(
|
|
1566
|
+
f"/Secrets-Safe/Folders/{folder_id}/Secrets/File",
|
|
1567
|
+
files=files,
|
|
1568
|
+
headers=headers,
|
|
1569
|
+
)
|
|
1570
|
+
response.raise_for_status()
|
|
1571
|
+
return response.json()
|
|
1572
|
+
|
|
1573
|
+
def update_text_secret(
|
|
1574
|
+
self: "PasswordSafeClient",
|
|
1575
|
+
secret_id: str,
|
|
1576
|
+
text: Optional[str] = None,
|
|
1577
|
+
title: Optional[str] = None,
|
|
1578
|
+
description: Optional[str] = None,
|
|
1579
|
+
notes: Optional[str] = None,
|
|
1580
|
+
) -> dict[str, Any]:
|
|
1581
|
+
"""Update a Text type secret.
|
|
1582
|
+
|
|
1583
|
+
Args:
|
|
1584
|
+
secret_id: Secret ID (GUID)
|
|
1585
|
+
text: New text content
|
|
1586
|
+
title: New title
|
|
1587
|
+
description: New description
|
|
1588
|
+
notes: New notes
|
|
1589
|
+
|
|
1590
|
+
Returns:
|
|
1591
|
+
Updated secret object
|
|
1592
|
+
"""
|
|
1593
|
+
# Get current - API requires ALL fields
|
|
1594
|
+
current = self.get_secret(secret_id)
|
|
1595
|
+
|
|
1596
|
+
data: dict[str, Any] = {
|
|
1597
|
+
"Title": title or current.get("Title"),
|
|
1598
|
+
"Text": text or current.get("Password"), # Text is in Password field
|
|
1599
|
+
"FolderId": current.get("FolderId"),
|
|
1600
|
+
"OwnerId": current.get("OwnerId"),
|
|
1601
|
+
"OwnerType": current.get("OwnerType", "User"),
|
|
1602
|
+
"Owners": current.get("Owners", []),
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if description is not None:
|
|
1606
|
+
data["Description"] = description
|
|
1607
|
+
elif current.get("Description"):
|
|
1608
|
+
data["Description"] = current["Description"]
|
|
1609
|
+
|
|
1610
|
+
if notes is not None:
|
|
1611
|
+
data["Notes"] = notes
|
|
1612
|
+
elif current.get("Notes"):
|
|
1613
|
+
data["Notes"] = current["Notes"]
|
|
1614
|
+
|
|
1615
|
+
return self.put(f"/Secrets-Safe/Secrets/{secret_id}/Text", json=data)
|
|
1616
|
+
|
|
1617
|
+
def update_file_secret(
|
|
1618
|
+
self: "PasswordSafeClient",
|
|
1619
|
+
secret_id: str,
|
|
1620
|
+
file_content: Optional[bytes] = None,
|
|
1621
|
+
file_name: Optional[str] = None,
|
|
1622
|
+
title: Optional[str] = None,
|
|
1623
|
+
description: Optional[str] = None,
|
|
1624
|
+
notes: Optional[str] = None,
|
|
1625
|
+
) -> dict[str, Any]:
|
|
1626
|
+
"""Update a File type secret.
|
|
1627
|
+
|
|
1628
|
+
Args:
|
|
1629
|
+
secret_id: Secret ID (GUID)
|
|
1630
|
+
file_content: New file content (if replacing file)
|
|
1631
|
+
file_name: New filename
|
|
1632
|
+
title: New title
|
|
1633
|
+
description: New description
|
|
1634
|
+
notes: New notes
|
|
1635
|
+
|
|
1636
|
+
Returns:
|
|
1637
|
+
Updated secret object
|
|
1638
|
+
"""
|
|
1639
|
+
import json as json_module
|
|
1640
|
+
|
|
1641
|
+
# Get current file info
|
|
1642
|
+
current = self.get(f"/Secrets-Safe/Secrets/{secret_id}/File")
|
|
1643
|
+
|
|
1644
|
+
metadata: dict[str, Any] = {
|
|
1645
|
+
"Title": title or current.get("Title"),
|
|
1646
|
+
"FileName": file_name or current.get("FileName"),
|
|
1647
|
+
"FolderId": current.get("FolderId"),
|
|
1648
|
+
"OwnerId": current.get("OwnerId"),
|
|
1649
|
+
"OwnerType": current.get("OwnerType", "User"),
|
|
1650
|
+
"Owners": current.get("Owners", []),
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
if description is not None:
|
|
1654
|
+
metadata["Description"] = description
|
|
1655
|
+
elif current.get("Description"):
|
|
1656
|
+
metadata["Description"] = current["Description"]
|
|
1657
|
+
|
|
1658
|
+
if notes is not None:
|
|
1659
|
+
metadata["Notes"] = notes
|
|
1660
|
+
elif current.get("Notes"):
|
|
1661
|
+
metadata["Notes"] = current["Notes"]
|
|
1662
|
+
|
|
1663
|
+
if file_content:
|
|
1664
|
+
# Multipart form data with new file
|
|
1665
|
+
client = self._ensure_client()
|
|
1666
|
+
headers = self._get_auth_headers()
|
|
1667
|
+
headers.pop("Content-Type", None)
|
|
1668
|
+
|
|
1669
|
+
files = {
|
|
1670
|
+
"secretmetadata": (None, json_module.dumps(metadata), "application/json"),
|
|
1671
|
+
"file": (metadata["FileName"], file_content, "application/octet-stream"),
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
response = client.put(
|
|
1675
|
+
f"/Secrets-Safe/Secrets/{secret_id}/File",
|
|
1676
|
+
files=files,
|
|
1677
|
+
headers=headers,
|
|
1678
|
+
)
|
|
1679
|
+
response.raise_for_status()
|
|
1680
|
+
return response.json()
|
|
1681
|
+
else:
|
|
1682
|
+
# Metadata-only update
|
|
1683
|
+
return self.put(f"/Secrets-Safe/Secrets/{secret_id}/File", json=metadata)
|
|
1684
|
+
|
|
1685
|
+
def get_file_content(self: "PasswordSafeClient", secret_id: str) -> bytes:
|
|
1686
|
+
"""Get the file content of a File type secret.
|
|
1687
|
+
|
|
1688
|
+
Args:
|
|
1689
|
+
secret_id: Secret ID (GUID)
|
|
1690
|
+
|
|
1691
|
+
Returns:
|
|
1692
|
+
File content as bytes
|
|
1693
|
+
"""
|
|
1694
|
+
client = self._ensure_client()
|
|
1695
|
+
headers = self._get_auth_headers()
|
|
1696
|
+
# Remove JSON content-type for file download
|
|
1697
|
+
headers.pop("Content-Type", None)
|
|
1698
|
+
|
|
1699
|
+
response = client.get(
|
|
1700
|
+
f"/Secrets-Safe/Secrets/{secret_id}/File/Download",
|
|
1701
|
+
headers=headers,
|
|
1702
|
+
)
|
|
1703
|
+
response.raise_for_status()
|
|
1704
|
+
return response.content
|
|
1705
|
+
|
|
1706
|
+
# =========================================================================
|
|
1707
|
+
# Secrets Safe - Shares
|
|
1708
|
+
# =========================================================================
|
|
1709
|
+
|
|
1710
|
+
def list_shares(
|
|
1711
|
+
self: "PasswordSafeClient", secret_id: str
|
|
1712
|
+
) -> list[dict[str, Any]]:
|
|
1713
|
+
"""List all shares of a secret.
|
|
1714
|
+
|
|
1715
|
+
Args:
|
|
1716
|
+
secret_id: Secret ID (GUID)
|
|
1717
|
+
|
|
1718
|
+
Returns:
|
|
1719
|
+
List of share objects
|
|
1720
|
+
"""
|
|
1721
|
+
return self.get(f"/Secrets-Safe/Secrets/{secret_id}/Shares")
|
|
1722
|
+
|
|
1723
|
+
def share_secret(
|
|
1724
|
+
self: "PasswordSafeClient",
|
|
1725
|
+
secret_id: str,
|
|
1726
|
+
folder_id: str,
|
|
1727
|
+
) -> dict[str, Any]:
|
|
1728
|
+
"""Share a secret to another folder.
|
|
1729
|
+
|
|
1730
|
+
Args:
|
|
1731
|
+
secret_id: Secret ID (GUID) to share
|
|
1732
|
+
folder_id: Target folder ID (GUID)
|
|
1733
|
+
|
|
1734
|
+
Returns:
|
|
1735
|
+
Share object with SecretId, FolderId, FolderPath, SecretName
|
|
1736
|
+
"""
|
|
1737
|
+
# POST /secrets-safe/secrets/{secretId}/shares/{folderId}
|
|
1738
|
+
return self.post(f"/Secrets-Safe/Secrets/{secret_id}/Shares/{folder_id}")
|
|
1739
|
+
|
|
1740
|
+
def delete_share(
|
|
1741
|
+
self: "PasswordSafeClient",
|
|
1742
|
+
secret_id: str,
|
|
1743
|
+
share_id: str,
|
|
1744
|
+
) -> dict[str, Any]:
|
|
1745
|
+
"""Delete a specific share.
|
|
1746
|
+
|
|
1747
|
+
Args:
|
|
1748
|
+
secret_id: Secret ID (GUID)
|
|
1749
|
+
share_id: Share ID (GUID)
|
|
1750
|
+
|
|
1751
|
+
Returns:
|
|
1752
|
+
Empty response on success
|
|
1753
|
+
"""
|
|
1754
|
+
return self.delete(f"/Secrets-Safe/Secrets/{secret_id}/Shares/{share_id}")
|
|
1755
|
+
|
|
1756
|
+
def delete_all_shares(
|
|
1757
|
+
self: "PasswordSafeClient", secret_id: str
|
|
1758
|
+
) -> dict[str, Any]:
|
|
1759
|
+
"""Delete all shares of a secret.
|
|
1760
|
+
|
|
1761
|
+
Args:
|
|
1762
|
+
secret_id: Secret ID (GUID)
|
|
1763
|
+
|
|
1764
|
+
Returns:
|
|
1765
|
+
Empty response on success
|
|
1766
|
+
"""
|
|
1767
|
+
return self.delete(f"/Secrets-Safe/Secrets/{secret_id}/Shares")
|
|
1768
|
+
|
|
1769
|
+
def move_secret(
|
|
1770
|
+
self: "PasswordSafeClient",
|
|
1771
|
+
secret_id: str,
|
|
1772
|
+
folder_id: str,
|
|
1773
|
+
) -> dict[str, Any]:
|
|
1774
|
+
"""Move a secret to a different folder.
|
|
1775
|
+
|
|
1776
|
+
Args:
|
|
1777
|
+
secret_id: Secret ID (GUID)
|
|
1778
|
+
folder_id: Target folder ID (GUID)
|
|
1779
|
+
|
|
1780
|
+
Returns:
|
|
1781
|
+
Updated secret object
|
|
1782
|
+
"""
|
|
1783
|
+
return self.put(
|
|
1784
|
+
f"/Secrets-Safe/Secrets/{secret_id}/Move",
|
|
1785
|
+
json={"FolderId": folder_id},
|
|
1786
|
+
)
|