workspaces-euc-mcp-server 0.1.1__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.
@@ -0,0 +1,152 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """Best-effort WorkSpaces price lookups (AWS Price List API) for savings estimates.
5
+
6
+ The Price List API is messy, so this is deliberately conservative: it matches the canonical
7
+ Included-license hardware SKU for a given region / OS / compute type / volume sizes and returns the
8
+ AlwaysOn monthly price plus the AutoStop monthly-base and hourly prices. Anything it cannot match
9
+ cleanly returns None, so callers degrade to "no estimate" rather than a wrong number.
10
+
11
+ Needs ``pricing:GetProducts`` (IAM Tier 1). The pricing client is global (queried from us-east-1).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import NamedTuple
18
+
19
+ from loguru import logger
20
+
21
+ from ..clients import ClientFactory
22
+
23
+ _PRICING_REGION = "us-east-1"
24
+
25
+ # Region -> Price List "location" long name. Extend as needed; unknown regions -> no estimate.
26
+ _REGION_LOCATIONS = {
27
+ "us-east-1": "US East (N. Virginia)",
28
+ "us-west-2": "US West (Oregon)",
29
+ "eu-west-1": "Europe (Ireland)",
30
+ "eu-central-1": "Europe (Frankfurt)",
31
+ "ap-southeast-1": "Asia Pacific (Singapore)",
32
+ "ap-southeast-2": "Asia Pacific (Sydney)",
33
+ "ap-northeast-1": "Asia Pacific (Tokyo)",
34
+ "ap-south-1": "Asia Pacific (Mumbai)",
35
+ "ca-central-1": "Canada (Central)",
36
+ }
37
+
38
+ # WorkSpaces compute type -> Price List "bundle" name.
39
+ _COMPUTE_BUNDLE = {
40
+ "VALUE": "Value",
41
+ "STANDARD": "Standard",
42
+ "PERFORMANCE": "Performance",
43
+ "POWER": "Power",
44
+ "POWERPRO": "PowerPro",
45
+ }
46
+
47
+ _cache: dict[tuple, WorkspacePrices | None] = {}
48
+
49
+
50
+ class WorkspacePrices(NamedTuple):
51
+ alwayson_monthly: float | None
52
+ autostop_monthly_base: float | None
53
+ autostop_hourly: float | None
54
+
55
+
56
+ def _os_filter(operating_system: str | None) -> str | None:
57
+ if not operating_system:
58
+ return "Windows"
59
+ o = operating_system.upper()
60
+ if "WIN" in o:
61
+ return "Windows"
62
+ if any(k in o for k in ("LINUX", "UBUNTU", "RHEL", "ROCKY", "AMAZON")):
63
+ return "Linux"
64
+ return None
65
+
66
+
67
+ def _storage_str(root_gib: int | None, user_gib: int | None) -> str | None:
68
+ if root_gib is None or user_gib is None:
69
+ return None
70
+ return f"Root:{root_gib} GB,User:{user_gib} GB"
71
+
72
+
73
+ def get_workspace_prices(
74
+ factory: ClientFactory,
75
+ region: str | None,
76
+ operating_system: str | None,
77
+ compute_type: str | None,
78
+ root_gib: int | None,
79
+ user_gib: int | None,
80
+ ) -> WorkspacePrices | None:
81
+ """Resolve AlwaysOn monthly + AutoStop base/hourly prices for a desktop (None if unmatched)."""
82
+ location = _REGION_LOCATIONS.get(region or "")
83
+ os_value = _os_filter(operating_system)
84
+ bundle = _COMPUTE_BUNDLE.get((compute_type or "").upper())
85
+ storage = _storage_str(root_gib, user_gib)
86
+ if not (location and os_value and bundle and storage):
87
+ return None
88
+
89
+ key = (location, os_value, bundle, storage)
90
+ if key in _cache:
91
+ return _cache[key]
92
+
93
+ base_filters = [
94
+ {"Type": "TERM_MATCH", "Field": "location", "Value": location},
95
+ {"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": os_value},
96
+ {"Type": "TERM_MATCH", "Field": "license", "Value": "Included"},
97
+ {"Type": "TERM_MATCH", "Field": "bundle", "Value": bundle},
98
+ ]
99
+ alwayson = autostop_base = hourly = None
100
+ try:
101
+ pricing = factory.client("pricing", region=_PRICING_REGION)
102
+ for running_mode in ("AlwaysOn", "AutoStop"):
103
+ filters = base_filters + [
104
+ {"Type": "TERM_MATCH", "Field": "runningMode", "Value": running_mode}
105
+ ]
106
+ resp = pricing.get_products(
107
+ ServiceCode="AmazonWorkSpaces", Filters=filters, MaxResults=100
108
+ )
109
+ for raw in resp.get("PriceList", []):
110
+ product = json.loads(raw)
111
+ attrs = product["product"]["attributes"]
112
+ if attrs.get("storage") != storage:
113
+ continue
114
+ for term in product.get("terms", {}).get("OnDemand", {}).values():
115
+ for pd in term["priceDimensions"].values():
116
+ unit = (pd.get("unit") or "").lower()
117
+ usd = float(pd.get("pricePerUnit", {}).get("USD", 0) or 0)
118
+ if usd <= 0:
119
+ continue
120
+ if running_mode == "AlwaysOn" and unit == "month":
121
+ alwayson = usd
122
+ elif running_mode == "AutoStop" and unit == "hour":
123
+ hourly = usd
124
+ elif running_mode == "AutoStop" and unit == "month":
125
+ autostop_base = usd
126
+ except Exception as exc: # pricing is best-effort; never fail the recommendation
127
+ logger.warning("Pricing lookup failed for {}: {}", key, exc)
128
+ _cache[key] = None
129
+ return None
130
+
131
+ prices = WorkspacePrices(alwayson, autostop_base, hourly)
132
+ _cache[key] = prices
133
+ return prices
134
+
135
+
136
+ def _estimated_monthly_hours(active_days: int | None, lookback_days: int) -> float:
137
+ """Rough monthly connected-hours estimate from active days (assume ~8h per active day)."""
138
+ if not active_days or lookback_days <= 0:
139
+ return 0.0
140
+ return (active_days / lookback_days) * 30.0 * 8.0
141
+
142
+
143
+ def estimate_alwayson_to_autostop_savings(
144
+ prices: WorkspacePrices | None, active_days: int | None, lookback_days: int
145
+ ) -> float | None:
146
+ """Monthly saving from moving an AlwaysOn desktop to AutoStop, given its usage."""
147
+ if not prices or prices.alwayson_monthly is None or prices.autostop_monthly_base is None:
148
+ return None
149
+ hours = _estimated_monthly_hours(active_days, lookback_days)
150
+ autostop_cost = prices.autostop_monthly_base + (prices.autostop_hourly or 0.0) * hours
151
+ saving = prices.alwayson_monthly - autostop_cost
152
+ return round(saving, 2) if saving > 0 else None
@@ -0,0 +1,529 @@
1
+ # Copyright bengroeneveldsg. Licensed under the Apache License, Version 2.0 (the "License").
2
+ # You may not use this file except in compliance with the License.
3
+ # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
+ """Reporting & audit tools (read-only).
5
+
6
+ `generate_inventory_report` and `list_unused_resources` are Tier 0; `audit_security_posture` is
7
+ Tier 0 too. All synthesize across services and degrade gracefully when a signal is unavailable.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from .. import consts
15
+ from ..clients import ClientFactory
16
+ from ..models import (
17
+ AuditReport,
18
+ Finding,
19
+ InventoryReport,
20
+ InventoryReportSection,
21
+ ResourceRecord,
22
+ ServiceError,
23
+ UnusedResource,
24
+ UnusedResourcesReport,
25
+ )
26
+ from . import cost
27
+ from ._common import paginate, read_only, try_call
28
+
29
+
30
+ def _managed_instance_record(instance: dict, ec2_by_id: dict[str, dict]) -> ResourceRecord:
31
+ ec2_id = (instance.get("EC2ManagedInstance") or {}).get("InstanceId")
32
+ ec2 = ec2_by_id.get(ec2_id or "", {})
33
+ return ResourceRecord(
34
+ id=instance.get("WorkspaceInstanceId", ""),
35
+ state=instance.get("ProvisionState"),
36
+ attributes={
37
+ "ec2_instance_id": ec2_id,
38
+ "ec2_instance_type": ec2.get("InstanceType"),
39
+ "ec2_state": (ec2.get("State") or {}).get("Name"),
40
+ "ec2_launch_time": str(ec2.get("LaunchTime")) if ec2.get("LaunchTime") else None,
41
+ "ec2_private_ip": ec2.get("PrivateIpAddress"),
42
+ "ec2_platform": ec2.get("PlatformDetails"),
43
+ },
44
+ )
45
+
46
+
47
+ def generate_inventory_report_core(factory: ClientFactory, region: str | None) -> InventoryReport:
48
+ errors: list[ServiceError] = []
49
+ sections: list[InventoryReportSection] = []
50
+
51
+ workspaces = factory.client(consts.WORKSPACES_API, region=region)
52
+ appstream = factory.client(consts.APPSTREAM_API, region=region)
53
+ secure_browser = factory.client(consts.SECURE_BROWSER_API, region=region)
54
+
55
+ personal = try_call(
56
+ errors,
57
+ consts.PRODUCT_WORKSPACES_PERSONAL,
58
+ "DescribeWorkspaces",
59
+ lambda: paginate(workspaces.describe_workspaces, "Workspaces"),
60
+ default=[],
61
+ )
62
+ sections.append(
63
+ InventoryReportSection(
64
+ service=consts.PRODUCT_WORKSPACES_PERSONAL,
65
+ resource_type="WorkSpace",
66
+ resources=[
67
+ ResourceRecord(
68
+ id=w.get("WorkspaceId", ""),
69
+ name=w.get("UserName"),
70
+ state=w.get("State"),
71
+ attributes={
72
+ "user_name": w.get("UserName"),
73
+ "computer_name": w.get("ComputerName"),
74
+ "ip_address": w.get("IpAddress"),
75
+ "directory_id": w.get("DirectoryId"),
76
+ "bundle_id": w.get("BundleId"),
77
+ "compute_type": w.get("WorkspaceProperties", {}).get("ComputeTypeName"),
78
+ "running_mode": w.get("WorkspaceProperties", {}).get("RunningMode"),
79
+ "operating_system": w.get("WorkspaceProperties", {}).get(
80
+ "OperatingSystemName"
81
+ ),
82
+ "protocols": w.get("WorkspaceProperties", {}).get("Protocols"),
83
+ "root_volume_gib": w.get("WorkspaceProperties", {}).get(
84
+ "RootVolumeSizeGib"
85
+ ),
86
+ "user_volume_gib": w.get("WorkspaceProperties", {}).get(
87
+ "UserVolumeSizeGib"
88
+ ),
89
+ "auto_stop_timeout_minutes": w.get("WorkspaceProperties", {}).get(
90
+ "RunningModeAutoStopTimeoutInMinutes"
91
+ ),
92
+ "root_volume_encrypted": w.get("RootVolumeEncryptionEnabled"),
93
+ "user_volume_encrypted": w.get("UserVolumeEncryptionEnabled"),
94
+ "subnet_id": w.get("SubnetId"),
95
+ },
96
+ )
97
+ for w in (personal or [])
98
+ ],
99
+ )
100
+ )
101
+
102
+ pools = try_call(
103
+ errors,
104
+ consts.PRODUCT_WORKSPACES_POOLS,
105
+ "DescribeWorkspacesPools",
106
+ lambda: paginate(workspaces.describe_workspaces_pools, "WorkspacesPools"),
107
+ default=[],
108
+ )
109
+ sections.append(
110
+ InventoryReportSection(
111
+ service=consts.PRODUCT_WORKSPACES_POOLS,
112
+ resource_type="WorkSpacesPool",
113
+ resources=[
114
+ ResourceRecord(
115
+ id=p.get("PoolId", p.get("PoolName", "")),
116
+ name=p.get("PoolName"),
117
+ state=p.get("State"),
118
+ attributes={
119
+ "capacity": p.get("CapacityStatus"),
120
+ "bundle_id": p.get("BundleId"),
121
+ "directory_id": p.get("DirectoryId"),
122
+ "running_mode": p.get("RunningMode"),
123
+ "description": p.get("Description"),
124
+ "created_at": str(p.get("CreatedAt")) if p.get("CreatedAt") else None,
125
+ "errors": p.get("Errors"),
126
+ },
127
+ )
128
+ for p in (pools or [])
129
+ ],
130
+ )
131
+ )
132
+
133
+ fleets = try_call(
134
+ errors,
135
+ consts.PRODUCT_WORKSPACES_APPLICATIONS,
136
+ "DescribeFleets",
137
+ lambda: paginate(appstream.describe_fleets, "Fleets"),
138
+ default=[],
139
+ )
140
+ sections.append(
141
+ InventoryReportSection(
142
+ service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
143
+ resource_type="Fleet",
144
+ resources=[
145
+ ResourceRecord(
146
+ id=f.get("Name", ""),
147
+ name=f.get("DisplayName"),
148
+ state=f.get("State"),
149
+ attributes={
150
+ "instance_type": f.get("InstanceType"),
151
+ "fleet_type": f.get("FleetType"),
152
+ "capacity": f.get("ComputeCapacityStatus"),
153
+ "image_name": f.get("ImageName"),
154
+ "max_user_duration_seconds": f.get("MaxUserDurationInSeconds"),
155
+ "idle_disconnect_timeout_seconds": f.get("IdleDisconnectTimeoutInSeconds"),
156
+ "disconnect_timeout_seconds": f.get("DisconnectTimeoutInSeconds"),
157
+ "max_sessions_per_instance": f.get("MaxSessionsPerInstance"),
158
+ "default_internet_access": f.get("EnableDefaultInternetAccess"),
159
+ },
160
+ )
161
+ for f in (fleets or [])
162
+ ],
163
+ )
164
+ )
165
+
166
+ stacks = try_call(
167
+ errors,
168
+ consts.PRODUCT_WORKSPACES_APPLICATIONS,
169
+ "DescribeStacks",
170
+ lambda: paginate(appstream.describe_stacks, "Stacks"),
171
+ default=[],
172
+ )
173
+ stack_records: list[ResourceRecord] = []
174
+ for s in stacks or []:
175
+ stack_name = s.get("Name", "")
176
+ associated = try_call(
177
+ errors,
178
+ consts.PRODUCT_WORKSPACES_APPLICATIONS,
179
+ "ListAssociatedFleets",
180
+ lambda stack_name=stack_name: paginate(
181
+ appstream.list_associated_fleets, "Names", StackName=stack_name
182
+ ),
183
+ default=[],
184
+ )
185
+ stack_records.append(
186
+ ResourceRecord(
187
+ id=stack_name,
188
+ name=s.get("DisplayName"),
189
+ attributes={
190
+ "description": s.get("Description"),
191
+ "associated_fleets": associated or [],
192
+ "user_settings": s.get("UserSettings"),
193
+ "storage_connectors": s.get("StorageConnectors"),
194
+ "application_settings": s.get("ApplicationSettings"),
195
+ },
196
+ )
197
+ )
198
+ sections.append(
199
+ InventoryReportSection(
200
+ service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
201
+ resource_type="Stack",
202
+ resources=stack_records,
203
+ )
204
+ )
205
+
206
+ portals = try_call(
207
+ errors,
208
+ consts.PRODUCT_SECURE_BROWSER,
209
+ "ListPortals",
210
+ lambda: paginate(
211
+ secure_browser.list_portals,
212
+ "portals",
213
+ pagination_in="nextToken",
214
+ pagination_out="nextToken",
215
+ ),
216
+ default=[],
217
+ )
218
+ sections.append(
219
+ InventoryReportSection(
220
+ service=consts.PRODUCT_SECURE_BROWSER,
221
+ resource_type="Portal",
222
+ resources=[
223
+ ResourceRecord(
224
+ id=p.get("portalArn", p.get("portalId", "")),
225
+ name=p.get("displayName"),
226
+ state=p.get("portalStatus"),
227
+ attributes={
228
+ "browser_type": p.get("browserType"),
229
+ "authentication_type": p.get("authenticationType"),
230
+ "max_concurrent_sessions": p.get("maxConcurrentSessions"),
231
+ "instance_type": p.get("instanceType"),
232
+ "renderer_type": p.get("rendererType"),
233
+ "portal_endpoint": p.get("portalEndpoint"),
234
+ },
235
+ )
236
+ for p in (portals or [])
237
+ ],
238
+ )
239
+ )
240
+
241
+ instances_client = factory.client(consts.WORKSPACES_INSTANCES_API, region=region)
242
+ instances = try_call(
243
+ errors,
244
+ consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
245
+ "ListWorkspaceInstances",
246
+ lambda: paginate(instances_client.list_workspace_instances, "WorkspaceInstances"),
247
+ default=[],
248
+ )
249
+ # Enrich with EC2 details (type/state/launch/IP) for the backing instances.
250
+ ec2_ids = [
251
+ eid
252
+ for i in (instances or [])
253
+ if (eid := (i.get("EC2ManagedInstance") or {}).get("InstanceId"))
254
+ ]
255
+ ec2_by_id: dict[str, dict] = {}
256
+ if ec2_ids:
257
+ ec2 = factory.client(consts.EC2_API, region=region)
258
+ reservations = try_call(
259
+ errors,
260
+ consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
261
+ "DescribeInstances",
262
+ lambda: ec2.describe_instances(InstanceIds=ec2_ids).get("Reservations", []),
263
+ default=[],
264
+ )
265
+ for res in reservations or []:
266
+ for inst in res.get("Instances", []):
267
+ ec2_by_id[inst.get("InstanceId", "")] = inst
268
+ sections.append(
269
+ InventoryReportSection(
270
+ service=consts.PRODUCT_WORKSPACES_CORE_INSTANCES,
271
+ resource_type="ManagedInstance",
272
+ resources=[_managed_instance_record(i, ec2_by_id) for i in (instances or [])],
273
+ )
274
+ )
275
+
276
+ total = sum(len(s.resources) for s in sections)
277
+ return InventoryReport(region=region, total_resources=total, sections=sections, errors=errors)
278
+
279
+
280
+ def audit_security_posture_core(factory: ClientFactory, region: str | None) -> AuditReport:
281
+ errors: list[ServiceError] = []
282
+ findings: list[Finding] = []
283
+ resources_checked: dict[str, int] = {}
284
+
285
+ workspaces = factory.client(consts.WORKSPACES_API, region=region)
286
+
287
+ personal = try_call(
288
+ errors,
289
+ consts.PRODUCT_WORKSPACES_PERSONAL,
290
+ "DescribeWorkspaces",
291
+ lambda: paginate(workspaces.describe_workspaces, "Workspaces"),
292
+ default=[],
293
+ )
294
+ resources_checked["workspaces"] = len(personal or [])
295
+ for w in personal or []:
296
+ wid = w.get("WorkspaceId", "")
297
+ root_enc = w.get("RootVolumeEncryptionEnabled")
298
+ user_enc = w.get("UserVolumeEncryptionEnabled")
299
+ unencrypted = [
300
+ name
301
+ for name, enabled in (("root", root_enc), ("user", user_enc))
302
+ if enabled is not True
303
+ ]
304
+ if unencrypted:
305
+ findings.append(
306
+ Finding(
307
+ severity="warning",
308
+ title=f"WorkSpace volumes not encrypted: {', '.join(unencrypted)}",
309
+ detail=f"WorkSpace {wid} has unencrypted {', '.join(unencrypted)} volume(s); "
310
+ "encryption can only be set at creation time.",
311
+ recommendation="Recreate the WorkSpace with root/user volume encryption.",
312
+ resource_id=wid,
313
+ )
314
+ )
315
+
316
+ directories = try_call(
317
+ errors,
318
+ consts.PRODUCT_WORKSPACES_PERSONAL,
319
+ "DescribeWorkspaceDirectories",
320
+ lambda: paginate(workspaces.describe_workspace_directories, "Directories"),
321
+ default=[],
322
+ )
323
+ resources_checked["directories"] = len(directories or [])
324
+ for d in directories or []:
325
+ did = d.get("DirectoryId", "")
326
+ ip_groups = d.get("ipGroupIds") or []
327
+ if not ip_groups:
328
+ findings.append(
329
+ Finding(
330
+ severity="warning",
331
+ title="Directory has no IP access control groups",
332
+ detail=f"Directory {did} has no IP access control groups, so WorkSpaces "
333
+ "connections are not restricted by source IP.",
334
+ recommendation="Attach an IP access control group to restrict trusted ranges.",
335
+ resource_id=did,
336
+ )
337
+ )
338
+
339
+ # Secure Browser portals: flag relaxed data-egress controls (download/copy/print enabled).
340
+ secure = factory.client(consts.SECURE_BROWSER_API, region=region)
341
+ portals = try_call(
342
+ errors,
343
+ consts.PRODUCT_SECURE_BROWSER,
344
+ "ListPortals",
345
+ lambda: paginate(
346
+ secure.list_portals, "portals", pagination_in="nextToken", pagination_out="nextToken"
347
+ ),
348
+ default=[],
349
+ )
350
+ resources_checked["portals"] = len(portals or [])
351
+ for p in portals or []:
352
+ arn = p.get("portalArn", "")
353
+ us_arn = p.get("userSettingsArn")
354
+ if not us_arn:
355
+ continue
356
+ us = try_call(
357
+ errors,
358
+ consts.PRODUCT_SECURE_BROWSER,
359
+ "GetUserSettings",
360
+ lambda us_arn=us_arn: secure.get_user_settings(userSettingsArn=us_arn).get(
361
+ "userSettings", {}
362
+ ),
363
+ default={},
364
+ )
365
+ enabled = [
366
+ flag for flag in consts.SECURE_BROWSER_EGRESS_FLAGS if (us or {}).get(flag) == "Enabled"
367
+ ]
368
+ if enabled:
369
+ findings.append(
370
+ Finding(
371
+ severity="warning",
372
+ title=f"Secure Browser portal allows data egress: {', '.join(enabled)}",
373
+ detail=f"Portal {p.get('displayName') or arn} permits {', '.join(enabled)} — "
374
+ "content can leave the managed browser session.",
375
+ recommendation="Disable unneeded download/copy/print in the user settings.",
376
+ resource_id=arn,
377
+ )
378
+ )
379
+
380
+ # Applications stacks: flag relaxed UserSettings that permit local data egress.
381
+ appstream = factory.client(consts.APPSTREAM_API, region=region)
382
+ stacks = try_call(
383
+ errors,
384
+ consts.PRODUCT_WORKSPACES_APPLICATIONS,
385
+ "DescribeStacks",
386
+ lambda: paginate(appstream.describe_stacks, "Stacks"),
387
+ default=[],
388
+ )
389
+ resources_checked["stacks"] = len(stacks or [])
390
+ egress_actions = {"CLIPBOARD_COPY_TO_LOCAL_DEVICE", "FILE_DOWNLOAD", "PRINTING_TO_LOCAL_DEVICE"}
391
+ for s in stacks or []:
392
+ name = s.get("Name", "")
393
+ enabled = [
394
+ u.get("Action")
395
+ for u in (s.get("UserSettings") or [])
396
+ if u.get("Action") in egress_actions and u.get("Permission") == "ENABLED"
397
+ ]
398
+ if enabled:
399
+ findings.append(
400
+ Finding(
401
+ severity="warning",
402
+ title=f"WorkSpaces Applications stack allows data egress: {', '.join(enabled)}",
403
+ detail=f"Stack {name} permits {', '.join(enabled)} to the local device.",
404
+ recommendation="Disable unneeded copy-to-local/file-download/local-printing "
405
+ "in the stack user settings.",
406
+ resource_id=name,
407
+ )
408
+ )
409
+
410
+ if not findings and any(resources_checked.values()):
411
+ findings.append(
412
+ Finding(
413
+ severity="info",
414
+ title="No posture issues found in the checks performed",
415
+ detail="Checked WorkSpace volume encryption, directory IP access groups, and "
416
+ "Secure Browser / Applications data-egress controls.",
417
+ )
418
+ )
419
+
420
+ severity_counts: dict[str, int] = {}
421
+ for f in findings:
422
+ severity_counts[f.severity] = severity_counts.get(f.severity, 0) + 1
423
+
424
+ return AuditReport(
425
+ region=region,
426
+ findings=findings,
427
+ severity_counts=severity_counts,
428
+ resources_checked=resources_checked,
429
+ errors=errors,
430
+ )
431
+
432
+
433
+ def list_unused_resources_core(
434
+ factory: ClientFactory, region: str | None, lookback_days: int = 14
435
+ ) -> UnusedResourcesReport:
436
+ errors: list[ServiceError] = []
437
+ items: list[UnusedResource] = []
438
+
439
+ utilization = cost.analyze_workspace_utilization_core(factory, region, lookback_days)
440
+ errors.extend(utilization.errors)
441
+ for w in utilization.workspaces:
442
+ if w.classification == "unused":
443
+ items.append(
444
+ UnusedResource(
445
+ service=consts.PRODUCT_WORKSPACES_PERSONAL,
446
+ resource_type="WorkSpace",
447
+ id=w.workspace_id,
448
+ reason=f"No user connections in the last {lookback_days} days.",
449
+ )
450
+ )
451
+
452
+ appstream = factory.client(consts.APPSTREAM_API, region=region)
453
+ fleets = try_call(
454
+ errors,
455
+ consts.PRODUCT_WORKSPACES_APPLICATIONS,
456
+ "DescribeFleets",
457
+ lambda: paginate(appstream.describe_fleets, "Fleets"),
458
+ default=[],
459
+ )
460
+ for f in fleets or []:
461
+ capacity = f.get("ComputeCapacityStatus", {})
462
+ if f.get("State") == "STOPPED" or capacity.get("Desired") == 0:
463
+ items.append(
464
+ UnusedResource(
465
+ service=consts.PRODUCT_WORKSPACES_APPLICATIONS,
466
+ resource_type="Fleet",
467
+ id=f.get("Name", ""),
468
+ reason="Fleet is stopped or has zero desired capacity.",
469
+ )
470
+ )
471
+
472
+ return UnusedResourcesReport(
473
+ region=region,
474
+ lookback_days=lookback_days,
475
+ items=items,
476
+ errors=errors,
477
+ notes=[
478
+ "WorkSpace usage is from the AWS/WorkSpaces UserConnected metric; review before any "
479
+ "termination — recently provisioned desktops can look unused."
480
+ ],
481
+ )
482
+
483
+
484
+ def register(mcp: Any, factory: ClientFactory) -> None:
485
+ """Register reporting & audit tools on the FastMCP app."""
486
+
487
+ async def generate_inventory_report(region: str | None = None) -> dict[str, Any]:
488
+ """Produce a detailed per-resource inventory across the EUC portfolio.
489
+
490
+ Lists WorkSpaces Personal desktops, WorkSpaces Pools, WorkSpaces Applications (formerly
491
+ AppStream 2.0) fleets, and WorkSpaces Secure Browser (formerly WorkSpaces Web) portals with
492
+ key attributes per resource. Read-only.
493
+
494
+ Args:
495
+ region: AWS region. Defaults to the server's configured region.
496
+ """
497
+ report = generate_inventory_report_core(factory, region or factory.region)
498
+ return report.model_dump()
499
+
500
+ async def audit_security_posture(region: str | None = None) -> dict[str, Any]:
501
+ """Audit EUC security posture against common best practices.
502
+
503
+ Checks WorkSpaces Personal root/user volume encryption and whether directories have IP
504
+ access control groups, returning severity-ranked findings. Read-only.
505
+
506
+ Args:
507
+ region: AWS region. Defaults to the server's configured region.
508
+ """
509
+ report = audit_security_posture_core(factory, region or factory.region)
510
+ return report.model_dump()
511
+
512
+ async def list_unused_resources(
513
+ region: str | None = None, lookback_days: int = 14
514
+ ) -> dict[str, Any]:
515
+ """List candidate idle/unused EUC resources worth reclaiming.
516
+
517
+ Surfaces unused WorkSpaces Personal desktops (no connections in the window) and stopped or
518
+ zero-capacity WorkSpaces Applications fleets. Read-only.
519
+
520
+ Args:
521
+ region: AWS region. Defaults to the server's configured region.
522
+ lookback_days: Window for WorkSpace usage (default 14).
523
+ """
524
+ report = list_unused_resources_core(factory, region or factory.region, lookback_days)
525
+ return report.model_dump()
526
+
527
+ mcp.add_tool(generate_inventory_report, annotations=read_only("Inventory report"))
528
+ mcp.add_tool(audit_security_posture, annotations=read_only("Security posture audit"))
529
+ mcp.add_tool(list_unused_resources, annotations=read_only("List unused resources"))