bt-cli 0.4.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,848 @@
1
+ """EPM Windows API client with OAuth 2.0 authentication."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import httpx
8
+
9
+ from bt_cli.core.config import EPMWConfig, load_epmw_config
10
+ from bt_cli.core.rest_debug import get_event_hooks
11
+ from bt_cli.core.client import _warn_ssl_disabled
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class EPMWClient:
17
+ """Client for BeyondTrust EPM Windows Management API.
18
+
19
+ Uses OAuth 2.0 client credentials flow for authentication.
20
+ Token endpoint: /oauth/token
21
+ API base path: /management-api/v3
22
+ Policy editor path: /policyeditor/v3
23
+ """
24
+
25
+ def __init__(self, config: EPMWConfig):
26
+ """Initialize EPMW client.
27
+
28
+ Args:
29
+ config: EPMW configuration with API URL and OAuth credentials
30
+ """
31
+ self.config = config
32
+ self.base_url = config.api_url.rstrip("/")
33
+ self.api_base = f"{self.base_url}/management-api/v3"
34
+ self.policy_editor_base = f"{self.base_url}/policyeditor/v3"
35
+ self._token: Optional[str] = None
36
+ self._token_expires: float = 0
37
+
38
+ if not config.verify_ssl:
39
+ _warn_ssl_disabled()
40
+
41
+ self._client = httpx.Client(
42
+ verify=config.verify_ssl,
43
+ timeout=config.timeout,
44
+ event_hooks=get_event_hooks(),
45
+ )
46
+
47
+ def _get_token(self) -> str:
48
+ """Get OAuth access token, refreshing if needed."""
49
+ if self._token and time.time() < self._token_expires - 60:
50
+ return self._token
51
+
52
+ token_url = f"{self.base_url}/oauth/token"
53
+ response = self._client.post(
54
+ token_url,
55
+ data={
56
+ "grant_type": "client_credentials",
57
+ "client_id": self.config.client_id,
58
+ "client_secret": self.config.client_secret,
59
+ },
60
+ )
61
+ response.raise_for_status()
62
+ data = response.json()
63
+ self._token = data["access_token"]
64
+ expires_in = data.get("expires_in", 3600)
65
+ self._token_expires = time.time() + expires_in
66
+ return self._token
67
+
68
+ def _headers(self) -> Dict[str, str]:
69
+ """Get request headers with auth token."""
70
+ return {
71
+ "Authorization": f"Bearer {self._get_token()}",
72
+ "Accept": "application/json",
73
+ "Content-Type": "application/json",
74
+ }
75
+
76
+ def get(
77
+ self,
78
+ endpoint: str,
79
+ params: Optional[Dict[str, Any]] = None,
80
+ ) -> Any:
81
+ """Make GET request to API.
82
+
83
+ Args:
84
+ endpoint: API endpoint path (e.g., "/Computers")
85
+ params: Optional query parameters
86
+
87
+ Returns:
88
+ JSON response data
89
+ """
90
+ url = f"{self.api_base}{endpoint}"
91
+ response = self._client.get(url, headers=self._headers(), params=params)
92
+ response.raise_for_status()
93
+ return response.json()
94
+
95
+ def get_paginated(
96
+ self,
97
+ endpoint: str,
98
+ params: Optional[Dict[str, Any]] = None,
99
+ page_size: int = 100,
100
+ ) -> List[Any]:
101
+ """Get all items from a paginated endpoint.
102
+
103
+ EPMW uses pageNumber/pageSize pagination with response format:
104
+ {"data": [...], "totalCount": N, "pageNumber": N, "pageSize": N}
105
+
106
+ Args:
107
+ endpoint: API endpoint path
108
+ params: Optional query parameters
109
+ page_size: Items per page (default 100)
110
+
111
+ Returns:
112
+ List of all items across all pages
113
+ """
114
+ all_items = []
115
+ current_page = 1
116
+ params = params or {}
117
+
118
+ while True:
119
+ params["pageSize"] = page_size
120
+ params["pageNumber"] = current_page
121
+
122
+ url = f"{self.api_base}{endpoint}"
123
+ response = self._client.get(url, headers=self._headers(), params=params)
124
+ response.raise_for_status()
125
+
126
+ result = response.json()
127
+
128
+ # Handle paginated response format
129
+ if isinstance(result, dict) and "data" in result:
130
+ items = result.get("data", [])
131
+ total_count = result.get("totalCount", 0)
132
+ else:
133
+ # Non-paginated response
134
+ items = result if isinstance(result, list) else [result]
135
+ total_count = len(items)
136
+
137
+ if not items:
138
+ break
139
+
140
+ all_items.extend(items)
141
+
142
+ # Check if we've fetched all items
143
+ if len(all_items) >= total_count:
144
+ break
145
+
146
+ current_page += 1
147
+
148
+ return all_items
149
+
150
+ def post(
151
+ self,
152
+ endpoint: str,
153
+ json: Optional[Dict[str, Any]] = None,
154
+ ) -> Any:
155
+ """Make POST request to API.
156
+
157
+ Args:
158
+ endpoint: API endpoint path
159
+ json: JSON body data
160
+
161
+ Returns:
162
+ JSON response data or None for 204 responses
163
+ """
164
+ url = f"{self.api_base}{endpoint}"
165
+ response = self._client.post(url, headers=self._headers(), json=json)
166
+ response.raise_for_status()
167
+ if response.status_code == 204:
168
+ return None
169
+ if response.text:
170
+ return response.json()
171
+ return None
172
+
173
+ def put(
174
+ self,
175
+ endpoint: str,
176
+ json: Optional[Dict[str, Any]] = None,
177
+ ) -> Any:
178
+ """Make PUT request to API.
179
+
180
+ Args:
181
+ endpoint: API endpoint path
182
+ json: JSON body data
183
+
184
+ Returns:
185
+ JSON response data
186
+ """
187
+ url = f"{self.api_base}{endpoint}"
188
+ response = self._client.put(url, headers=self._headers(), json=json)
189
+ response.raise_for_status()
190
+ if response.status_code == 204 or not response.text:
191
+ return None
192
+ return response.json()
193
+
194
+ def patch(
195
+ self,
196
+ endpoint: str,
197
+ json: Optional[Dict[str, Any]] = None,
198
+ ) -> Any:
199
+ """Make PATCH request to API.
200
+
201
+ Args:
202
+ endpoint: API endpoint path
203
+ json: JSON body data
204
+
205
+ Returns:
206
+ JSON response data
207
+ """
208
+ url = f"{self.api_base}{endpoint}"
209
+ response = self._client.patch(url, headers=self._headers(), json=json)
210
+ response.raise_for_status()
211
+ if response.status_code == 204 or not response.text:
212
+ return None
213
+ return response.json()
214
+
215
+ def delete(self, endpoint: str) -> None:
216
+ """Make DELETE request to API.
217
+
218
+ Args:
219
+ endpoint: API endpoint path
220
+ """
221
+ url = f"{self.api_base}{endpoint}"
222
+ response = self._client.delete(url, headers=self._headers())
223
+ response.raise_for_status()
224
+
225
+ def close(self) -> None:
226
+ """Close the HTTP client."""
227
+ self._client.close()
228
+
229
+ def __enter__(self) -> "EPMWClient":
230
+ return self
231
+
232
+ def __exit__(self, *args) -> None:
233
+ self.close()
234
+
235
+ # Convenience methods for common resources
236
+
237
+ def list_computers(
238
+ self,
239
+ filter_str: Optional[str] = None,
240
+ sort_by: Optional[str] = None,
241
+ sort_order: Optional[str] = None,
242
+ ) -> List[Dict[str, Any]]:
243
+ """List all computers.
244
+
245
+ Args:
246
+ filter_str: OData filter string
247
+ sort_by: Field to sort by
248
+ sort_order: 'asc' or 'desc'
249
+ """
250
+ params = {}
251
+ if filter_str:
252
+ params["filter"] = filter_str
253
+ if sort_by:
254
+ params["sortBy"] = sort_by
255
+ if sort_order:
256
+ params["sortOrder"] = sort_order
257
+ return self.get_paginated("/Computers", params)
258
+
259
+ def get_computer(self, computer_id: str) -> Dict[str, Any]:
260
+ """Get a specific computer."""
261
+ return self.get(f"/Computers/{computer_id}")
262
+
263
+ def delete_computer(self, computer_id: str) -> None:
264
+ """Delete a computer."""
265
+ self.delete(f"/Computers/{computer_id}")
266
+
267
+ def archive_computer(self, computer_id: str) -> None:
268
+ """Archive a computer."""
269
+ self.post(f"/Computers/{computer_id}/archive")
270
+
271
+ def unarchive_computer(self, computer_id: str) -> None:
272
+ """Unarchive a computer."""
273
+ self.post(f"/Computers/{computer_id}/Unarchive")
274
+
275
+ def list_groups(self) -> List[Dict[str, Any]]:
276
+ """List all groups."""
277
+ return self.get_paginated("/Groups")
278
+
279
+ def get_group(self, group_id: str) -> Dict[str, Any]:
280
+ """Get a specific group."""
281
+ return self.get(f"/Groups/{group_id}")
282
+
283
+ def create_group(self, data: Dict[str, Any]) -> Dict[str, Any]:
284
+ """Create a new group."""
285
+ return self.post("/Groups", json=data)
286
+
287
+ def update_group(self, group_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
288
+ """Update a group."""
289
+ return self.put(f"/Groups/{group_id}", json=data)
290
+
291
+ def delete_group(self, group_id: str) -> None:
292
+ """Delete a group."""
293
+ self.delete(f"/Groups/{group_id}")
294
+
295
+ def assign_policy_to_group(
296
+ self, group_id: str, policy_revision_id: str
297
+ ) -> None:
298
+ """Assign a policy revision to a group."""
299
+ self.post(
300
+ f"/Groups/{group_id}/AssignPolicyRevision",
301
+ json={"policyRevisionId": policy_revision_id},
302
+ )
303
+
304
+ def assign_computers_to_group(
305
+ self, group_id: str, computer_ids: List[str]
306
+ ) -> None:
307
+ """Assign computers to a group."""
308
+ self.post(
309
+ f"/Groups/{group_id}/AssignComputers",
310
+ json={"computerIds": computer_ids},
311
+ )
312
+
313
+ def list_policies(self) -> List[Dict[str, Any]]:
314
+ """List all policies."""
315
+ return self.get_paginated("/Policies")
316
+
317
+ def get_policy(self, policy_id: str) -> Dict[str, Any]:
318
+ """Get a specific policy."""
319
+ return self.get(f"/Policies/{policy_id}")
320
+
321
+ def download_policy(self, policy_id: str) -> str:
322
+ """Download policy content (returns XML).
323
+
324
+ Args:
325
+ policy_id: Policy ID (UUID)
326
+
327
+ Returns:
328
+ Policy content as XML string
329
+ """
330
+ url = f"{self.api_base}/Policies/{policy_id}/Content"
331
+ response = self._client.get(url, headers=self._headers())
332
+ response.raise_for_status()
333
+ return response.text
334
+
335
+ def create_policy(
336
+ self, name: str, description: str = "", policy_file: str = ""
337
+ ) -> str:
338
+ """Create a new policy.
339
+
340
+ The API requires multipart form data with a file upload.
341
+
342
+ Args:
343
+ name: Policy name
344
+ description: Optional description
345
+ policy_file: Policy XML content (required by API)
346
+
347
+ Returns:
348
+ Created policy ID (UUID string)
349
+ """
350
+ url = f"{self.api_base}/Policies"
351
+ headers = self._headers()
352
+ # Remove Content-Type to let httpx set it for multipart
353
+ del headers["Content-Type"]
354
+
355
+ data = {"Name": name}
356
+ if description:
357
+ data["Description"] = description
358
+
359
+ files = {"PolicyFile": ("policy.xml", policy_file, "application/xml")}
360
+
361
+ response = self._client.post(url, headers=headers, data=data, files=files)
362
+ response.raise_for_status()
363
+ # API returns just the policy ID as a string
364
+ return response.text.strip('"')
365
+
366
+ def delete_policy(self, policy_id: str) -> None:
367
+ """Delete a policy.
368
+
369
+ Args:
370
+ policy_id: Policy ID (UUID)
371
+ """
372
+ self.delete(f"/Policies/{policy_id}")
373
+
374
+ def update_policy(self, policy_id: str, name: str, description: str = "") -> Dict[str, Any]:
375
+ """Update a policy.
376
+
377
+ Args:
378
+ policy_id: Policy ID (UUID)
379
+ name: New policy name
380
+ description: New description
381
+
382
+ Returns:
383
+ Updated policy object
384
+ """
385
+ data = {"name": name}
386
+ if description:
387
+ data["description"] = description
388
+ return self.put(f"/Policies/{policy_id}", json=data)
389
+
390
+ def revert_policy(self, policy_id: str) -> None:
391
+ """Revert a policy to discard draft changes.
392
+
393
+ Args:
394
+ policy_id: Policy ID (UUID)
395
+ """
396
+ self.patch(f"/Policies/{policy_id}")
397
+
398
+ def list_policy_revisions(self, policy_id: str) -> List[Dict[str, Any]]:
399
+ """List all revisions of a policy.
400
+
401
+ Args:
402
+ policy_id: Policy ID (UUID)
403
+
404
+ Returns:
405
+ List of revision objects
406
+ """
407
+ return self.get_paginated(f"/Policies/{policy_id}/revisions")
408
+
409
+ def get_policy_revision(self, policy_id: str, revision_id: str) -> Any:
410
+ """Download a specific policy revision.
411
+
412
+ Args:
413
+ policy_id: Policy ID (UUID)
414
+ revision_id: Revision ID (UUID)
415
+
416
+ Returns:
417
+ Policy revision content
418
+ """
419
+ return self.get(f"/Policies/{policy_id}/revisions/{revision_id}")
420
+
421
+ def upload_policy_revision(
422
+ self,
423
+ policy_id: str,
424
+ content: Dict[str, Any],
425
+ comment: str = "",
426
+ ) -> Dict[str, Any]:
427
+ """Upload a new policy revision.
428
+
429
+ Args:
430
+ policy_id: Policy ID (UUID)
431
+ content: Policy content (JSON structure)
432
+ comment: Optional revision comment
433
+
434
+ Returns:
435
+ Upload result with revision info
436
+ """
437
+ data = {"content": content}
438
+ if comment:
439
+ data["comment"] = comment
440
+ return self.post(f"/Policies/{policy_id}/revisions", json=data)
441
+
442
+ def get_policy_groups(self, policy_id: str) -> List[Dict[str, Any]]:
443
+ """Get groups assigned to a policy.
444
+
445
+ Note: The API doesn't have a direct endpoint for this, so we get all groups
446
+ and filter by policyId.
447
+
448
+ Args:
449
+ policy_id: Policy ID (UUID)
450
+
451
+ Returns:
452
+ List of group objects that have this policy assigned
453
+ """
454
+ all_groups = self.list_groups()
455
+ return [g for g in all_groups if g.get("policyId") == policy_id]
456
+
457
+ # Policy Editor methods (different base path: /policyeditor/v3)
458
+
459
+ def _policy_editor_get(
460
+ self,
461
+ endpoint: str,
462
+ params: Optional[Dict[str, Any]] = None,
463
+ ) -> Any:
464
+ """Make GET request to policy editor API."""
465
+ url = f"{self.policy_editor_base}{endpoint}"
466
+ response = self._client.get(url, headers=self._headers(), params=params)
467
+ response.raise_for_status()
468
+ return response.json()
469
+
470
+ def _policy_editor_post(
471
+ self,
472
+ endpoint: str,
473
+ json: Optional[Dict[str, Any]] = None,
474
+ ) -> Any:
475
+ """Make POST request to policy editor API."""
476
+ url = f"{self.policy_editor_base}{endpoint}"
477
+ response = self._client.post(url, headers=self._headers(), json=json)
478
+ response.raise_for_status()
479
+ if response.status_code == 204 or not response.text:
480
+ return None
481
+ return response.json()
482
+
483
+ def _policy_editor_put(
484
+ self,
485
+ endpoint: str,
486
+ json: Optional[Dict[str, Any]] = None,
487
+ ) -> Any:
488
+ """Make PUT request to policy editor API."""
489
+ url = f"{self.policy_editor_base}{endpoint}"
490
+ response = self._client.put(url, headers=self._headers(), json=json)
491
+ response.raise_for_status()
492
+ if response.status_code == 204 or not response.text:
493
+ return None
494
+ return response.json()
495
+
496
+ def _policy_editor_delete(self, endpoint: str) -> None:
497
+ """Make DELETE request to policy editor API."""
498
+ url = f"{self.policy_editor_base}{endpoint}"
499
+ response = self._client.delete(url, headers=self._headers())
500
+ response.raise_for_status()
501
+
502
+ def list_application_groups(self, policy_id: str) -> List[Dict[str, Any]]:
503
+ """List application groups in a policy.
504
+
505
+ Args:
506
+ policy_id: Policy ID (UUID)
507
+
508
+ Returns:
509
+ List of application group objects
510
+ """
511
+ result = self._policy_editor_get(
512
+ f"/policy/{policy_id}/windows/applicationgroups"
513
+ )
514
+ if isinstance(result, list):
515
+ return result
516
+ return result.get("data", result.get("applicationGroups", []))
517
+
518
+ def get_application_group(
519
+ self, policy_id: str, app_group_id: str
520
+ ) -> Dict[str, Any]:
521
+ """Get a specific application group.
522
+
523
+ Args:
524
+ policy_id: Policy ID (UUID)
525
+ app_group_id: Application group ID (UUID)
526
+
527
+ Returns:
528
+ Application group object
529
+ """
530
+ return self._policy_editor_get(
531
+ f"/policy/{policy_id}/windows/applicationgroups/{app_group_id}"
532
+ )
533
+
534
+ def create_application_group(
535
+ self,
536
+ policy_id: str,
537
+ name: str,
538
+ description: str = "",
539
+ hidden: bool = False,
540
+ ) -> Dict[str, Any]:
541
+ """Create an application group in a policy.
542
+
543
+ Args:
544
+ policy_id: Policy ID (UUID)
545
+ name: Application group name
546
+ description: Optional description
547
+ hidden: Whether the group is hidden
548
+
549
+ Returns:
550
+ Created application group object
551
+ """
552
+ data = {
553
+ "name": name,
554
+ "description": description,
555
+ "hidden": hidden,
556
+ }
557
+ return self._policy_editor_post(
558
+ f"/policy/{policy_id}/windows/applicationgroups",
559
+ json=data,
560
+ )
561
+
562
+ def update_application_group(
563
+ self,
564
+ policy_id: str,
565
+ app_group_id: str,
566
+ name: str,
567
+ description: str = "",
568
+ hidden: bool = False,
569
+ ) -> Dict[str, Any]:
570
+ """Update an application group.
571
+
572
+ Args:
573
+ policy_id: Policy ID (UUID)
574
+ app_group_id: Application group ID (UUID)
575
+ name: New name
576
+ description: New description
577
+ hidden: Whether the group is hidden
578
+
579
+ Returns:
580
+ Updated application group object
581
+ """
582
+ data = {
583
+ "name": name,
584
+ "description": description,
585
+ "hidden": hidden,
586
+ }
587
+ return self._policy_editor_put(
588
+ f"/policy/{policy_id}/windows/applicationgroups/{app_group_id}",
589
+ json=data,
590
+ )
591
+
592
+ def delete_application_group(self, policy_id: str, app_group_id: str) -> None:
593
+ """Delete an application group.
594
+
595
+ Args:
596
+ policy_id: Policy ID (UUID)
597
+ app_group_id: Application group ID (UUID)
598
+ """
599
+ self._policy_editor_delete(
600
+ f"/policy/{policy_id}/windows/applicationgroups/{app_group_id}"
601
+ )
602
+
603
+ def list_roles(self) -> List[Dict[str, Any]]:
604
+ """List all roles."""
605
+ return self.get_paginated("/Roles")
606
+
607
+ def get_role(self, role_id: str) -> Dict[str, Any]:
608
+ """Get a specific role."""
609
+ return self.get(f"/Roles/{role_id}")
610
+
611
+ def list_users(self) -> List[Dict[str, Any]]:
612
+ """List all users."""
613
+ return self.get_paginated("/Users")
614
+
615
+ def get_user(self, user_id: str) -> Dict[str, Any]:
616
+ """Get a specific user."""
617
+ return self.get(f"/Users/{user_id}")
618
+
619
+ def create_user(self, data: Dict[str, Any]) -> Dict[str, Any]:
620
+ """Create a new user."""
621
+ return self.post("/Users", json=data)
622
+
623
+ def update_user(self, user_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
624
+ """Update a user."""
625
+ return self.put(f"/Users/{user_id}", json=data)
626
+
627
+ def enable_user(self, user_id: str) -> None:
628
+ """Enable a user."""
629
+ self.patch(f"/Users/{user_id}/Enable")
630
+
631
+ def disable_user(self, user_id: str) -> None:
632
+ """Disable a user."""
633
+ self.patch(f"/Users/{user_id}/Disable")
634
+
635
+ def assign_roles_to_user(self, user_id: str, role_ids: List[str]) -> None:
636
+ """Assign roles to a user."""
637
+ self.post(f"/Users/{user_id}/AssignRoles", json={"roleIds": role_ids})
638
+
639
+ def list_admin_requests(self) -> List[Dict[str, Any]]:
640
+ """List admin access requests."""
641
+ return self.get_paginated("/AdminAccessRequest")
642
+
643
+ def get_admin_request(self, request_id: str) -> Dict[str, Any]:
644
+ """Get a specific admin access request."""
645
+ return self.get(f"/AdminAccessRequest/{request_id}")
646
+
647
+ def create_admin_request(self, data: Dict[str, Any]) -> Dict[str, Any]:
648
+ """Create an admin access request."""
649
+ return self.post("/AdminAccessRequest", json=data)
650
+
651
+ def approve_admin_request(
652
+ self,
653
+ request_id: str,
654
+ message: str,
655
+ duration: int = 1800,
656
+ performed_by: str = "api-admin",
657
+ ) -> Dict[str, Any]:
658
+ """Approve an admin access request.
659
+
660
+ Args:
661
+ request_id: Request UUID
662
+ message: Approval message (required by API)
663
+ duration: Approval duration in seconds (default: 1800 = 30 min)
664
+ performed_by: Username performing the decision
665
+
666
+ Returns:
667
+ Response with requestId
668
+ """
669
+ return self.post(
670
+ "/AdminAccessRequest/approval",
671
+ json={
672
+ "RequestId": request_id,
673
+ "Decision": "Approved",
674
+ "DecisionPerformedByUser": performed_by,
675
+ "Duration": str(duration),
676
+ "Message": message,
677
+ },
678
+ )
679
+
680
+ def deny_admin_request(
681
+ self,
682
+ request_id: str,
683
+ message: str,
684
+ performed_by: str = "api-admin",
685
+ ) -> Dict[str, Any]:
686
+ """Deny an admin access request.
687
+
688
+ Args:
689
+ request_id: Request UUID
690
+ message: Denial message (required by API)
691
+ performed_by: Username performing the decision
692
+
693
+ Returns:
694
+ Response with requestId
695
+ """
696
+ return self.post(
697
+ "/AdminAccessRequest/approval",
698
+ json={
699
+ "RequestId": request_id,
700
+ "Decision": "Denied",
701
+ "DecisionPerformedByUser": performed_by,
702
+ "Message": message,
703
+ },
704
+ )
705
+
706
+ # Audit methods
707
+
708
+ def list_activity_audits(self) -> List[Dict[str, Any]]:
709
+ """List activity audit records."""
710
+ return self.get_paginated("/ActivityAudits")
711
+
712
+ def get_activity_audit(self, audit_id: int) -> Dict[str, Any]:
713
+ """Get a specific activity audit record."""
714
+ return self.get(f"/ActivityAudits/{audit_id}")
715
+
716
+ def list_authorization_requests(self) -> List[Dict[str, Any]]:
717
+ """List authorization requests (JIT app requests)."""
718
+ return self.get_paginated("/AuthorizationRequest")
719
+
720
+ def get_authorization_request(self, request_id: str) -> Dict[str, Any]:
721
+ """Get a specific authorization request."""
722
+ return self.get(f"/AuthorizationRequest/{request_id}")
723
+
724
+ def list_authorization_request_audits(self) -> List[Dict[str, Any]]:
725
+ """List authorization request audit records."""
726
+ return self.get_paginated("/AuthorizationRequestAudits")
727
+
728
+ def get_authorization_request_audit(self, audit_id: str) -> Dict[str, Any]:
729
+ """Get a specific authorization request audit record."""
730
+ return self.get(f"/AuthorizationRequestAudits/{audit_id}")
731
+
732
+ # Task methods
733
+
734
+ def get_task(self, task_id: str) -> Dict[str, Any]:
735
+ """Get task status."""
736
+ return self.get(f"/Tasks/{task_id}")
737
+
738
+ # Event methods
739
+
740
+ def list_events_from_date(
741
+ self,
742
+ start_date: str,
743
+ record_size: int = 1000,
744
+ ) -> List[Dict[str, Any]]:
745
+ """Get events from a start date.
746
+
747
+ Args:
748
+ start_date: Start date in ISO format (e.g., 2022-08-12T17:34:28.694Z)
749
+ record_size: Max records to return (1-1000, default 1000)
750
+
751
+ Returns:
752
+ List of events
753
+ """
754
+ params = {
755
+ "StartDate": start_date,
756
+ "RecordSize": min(max(record_size, 1), 1000),
757
+ }
758
+ response = self.get("/Events/FromStartDate", params=params)
759
+ # Response may be a list directly or wrapped
760
+ if isinstance(response, list):
761
+ return response
762
+ return response.get("data", response.get("events", []))
763
+
764
+ def search_events(
765
+ self,
766
+ start_date: str,
767
+ end_date: str,
768
+ computer_groups: Optional[List[str]] = None,
769
+ operating_system: Optional[str] = None,
770
+ event_actions: Optional[List[str]] = None,
771
+ event_codes: Optional[List[str]] = None,
772
+ event_types: Optional[List[str]] = None,
773
+ application_type: Optional[str] = None,
774
+ hostname: Optional[str] = None,
775
+ host_domain: Optional[str] = None,
776
+ username: Optional[str] = None,
777
+ user_domain: Optional[str] = None,
778
+ workstyle_name: Optional[str] = None,
779
+ application_group_name: Optional[str] = None,
780
+ on_demand_rule: Optional[bool] = None,
781
+ page_size: int = 100,
782
+ page_number: int = 1,
783
+ ) -> Dict[str, Any]:
784
+ """Search events with filters.
785
+
786
+ Args:
787
+ start_date: Start date in ISO format
788
+ end_date: End date in ISO format
789
+ computer_groups: List of group IDs (UUIDs)
790
+ operating_system: OS name filter
791
+ event_actions: List of event actions
792
+ event_codes: List of event codes
793
+ event_types: List of event types
794
+ application_type: Application type filter
795
+ hostname: Computer hostname filter
796
+ host_domain: Computer domain filter
797
+ username: User name filter
798
+ user_domain: User domain filter
799
+ workstyle_name: Policy workstyle filter
800
+ application_group_name: Policy app group filter
801
+ on_demand_rule: On-demand rule filter
802
+ page_size: Records per page (max 200)
803
+ page_number: Page number
804
+
805
+ Returns:
806
+ Dict with events and pagination info
807
+ """
808
+ params: Dict[str, Any] = {
809
+ "TimePeriod.StartDate": start_date,
810
+ "TimePeriod.EndDate": end_date,
811
+ "Pagination.PageSize": min(page_size, 200),
812
+ "Pagination.PageNumber": page_number,
813
+ }
814
+
815
+ if computer_groups:
816
+ params["ComputerGroups"] = computer_groups
817
+ if operating_system:
818
+ params["OperatingSystem"] = operating_system
819
+ if event_actions:
820
+ params["Events.EventAction"] = event_actions
821
+ if event_codes:
822
+ params["Events.EventCode"] = event_codes
823
+ if event_types:
824
+ params["Events.EventType"] = event_types
825
+ if application_type:
826
+ params["Application.ApplicationType"] = application_type
827
+ if hostname:
828
+ params["Computers.HostName"] = hostname
829
+ if host_domain:
830
+ params["Computers.HostDomain"] = host_domain
831
+ if username:
832
+ params["Users.UserName"] = username
833
+ if user_domain:
834
+ params["Users.UserDomain"] = user_domain
835
+ if workstyle_name:
836
+ params["Policies.WorkstyleName"] = workstyle_name
837
+ if application_group_name:
838
+ params["Policies.ApplicationGroupName"] = application_group_name
839
+ if on_demand_rule is not None:
840
+ params["Policies.OnDemandRule"] = on_demand_rule
841
+
842
+ return self.get("/Events/search", params=params)
843
+
844
+
845
+ def get_client() -> EPMWClient:
846
+ """Get an EPMW client with configuration from environment."""
847
+ config = load_epmw_config()
848
+ return EPMWClient(config)