bt-cli 0.4.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +88 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +98 -0
  20. bt_cli/data/skills/entitle/SKILL.md +159 -0
  21. bt_cli/data/skills/epmw/SKILL.md +145 -0
  22. bt_cli/data/skills/pra/SKILL.md +149 -0
  23. bt_cli/data/skills/pws/SKILL.md +197 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.7.dist-info/METADATA +172 -0
  119. bt_cli-0.4.7.dist-info/RECORD +121 -0
  120. bt_cli-0.4.7.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.7.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
+ )