workspaces-euc-mcp-server 0.1.5__tar.gz → 0.1.7__tar.gz

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 (53) hide show
  1. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/CHANGELOG.md +22 -0
  2. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/DESIGN.md +3 -3
  3. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/PKG-INFO +4 -4
  4. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/README.md +3 -3
  5. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/iam/tier0-diagnostics.json +3 -1
  6. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/iam/tier1-cost.json +3 -1
  7. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/iam/tier2-lifecycle.json +3 -1
  8. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/iam/tier3-destructive.json +3 -1
  9. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/pyproject.toml +1 -1
  10. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_diagnostics.py +39 -0
  11. workspaces_euc_mcp_server-0.1.7/tests/test_secure_browser.py +144 -0
  12. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/__init__.py +1 -1
  13. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/models.py +36 -0
  14. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/diagnostics.py +14 -1
  15. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/secure_browser.py +114 -18
  16. workspaces_euc_mcp_server-0.1.5/tests/test_secure_browser.py +0 -85
  17. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.dockerignore +0 -0
  18. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.github/workflows/ci.yml +0 -0
  19. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.github/workflows/docker-publish.yml +0 -0
  20. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.github/workflows/publish.yml +0 -0
  21. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.gitignore +0 -0
  22. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/.pre-commit-config.yaml +0 -0
  23. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/Dockerfile +0 -0
  24. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/LICENSE +0 -0
  25. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/iam/README.md +0 -0
  26. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/scripts/smoke_readonly.py +0 -0
  27. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/__init__.py +0 -0
  28. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_clients.py +0 -0
  29. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_cost.py +0 -0
  30. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_destructive.py +0 -0
  31. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_governance.py +0 -0
  32. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_images.py +0 -0
  33. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_inventory.py +0 -0
  34. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_lifecycle.py +0 -0
  35. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_naming.py +0 -0
  36. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_no_embedded_secrets.py +0 -0
  37. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_performance.py +0 -0
  38. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_pricing.py +0 -0
  39. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/tests/test_reporting.py +0 -0
  40. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/clients.py +0 -0
  41. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/consts.py +0 -0
  42. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/server.py +0 -0
  43. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/__init__.py +0 -0
  44. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/_common.py +0 -0
  45. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/cost.py +0 -0
  46. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/destructive.py +0 -0
  47. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/governance.py +0 -0
  48. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/images.py +0 -0
  49. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/inventory.py +0 -0
  50. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/lifecycle.py +0 -0
  51. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/performance.py +0 -0
  52. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/pricing.py +0 -0
  53. {workspaces_euc_mcp_server-0.1.5 → workspaces_euc_mcp_server-0.1.7}/workspaces_euc_mcp_server/tools/reporting.py +0 -0
@@ -5,6 +5,28 @@ All notable changes to this project are documented here. The format is based on
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.1.7] - 2026-06-02
9
+
10
+ ### Fixed
11
+ - `get_secure_browser_portal_usage` now reports **current active sessions live via
12
+ `workspaces-web:ListSessions`** (the same source as the console's active-sessions view) instead
13
+ of inferring "active" from CloudWatch. CloudWatch (`AWS/WorkSpacesWeb`) is now used **only for
14
+ historic** metrics, and the summary clearly separates live vs historic. Adds
15
+ `workspaces-web:ListSessions` to every IAM tier. The tool now returns a `SecureBrowserPortalUsage`
16
+ result (`active_session_count`, `active_sessions`, `historic_metrics`).
17
+
18
+ ## [0.1.6] - 2026-06-02
19
+
20
+ ### Added
21
+ - `check_directory_health` now surfaces each directory's **registration properties** in its
22
+ signals — notably the target **OU** (`WorkspaceCreationProperties.DefaultOu`), plus custom
23
+ security group, local-admin / internet-access / maintenance-mode flags, and directory/workspace
24
+ type. (AD-backed directories carry an OU; WorkSpaces-managed ones return none.)
25
+ - `get_secure_browser_portal_details` now resolves the portal's **data-protection configuration**
26
+ when attached: the redacted built-in/custom inline-redaction patterns, global confidence level,
27
+ and enforced/exempt URLs — not just whether data protection is on. Adds
28
+ `workspaces-web:GetDataProtectionSettings` to every IAM tier.
29
+
8
30
  ## [0.1.5] - 2026-06-02
9
31
 
10
32
  ### Added
@@ -117,7 +117,7 @@ and return a synthesized result, not raw API passthroughs.
117
117
  | `diagnose_workspace_connectivity` | Correlate a Personal WorkSpace's state + connection status + directory health + CloudWatch into a root-cause narrative | `workspaces:Describe*`, `ds:DescribeDirectories`, `cloudwatch:GetMetricData` |
118
118
  | `diagnose_pool` | Why a Pool is unhealthy/queued — capacity status, sessions, errors, scaling | `workspaces:DescribeWorkspacesPool*`, `cloudwatch:GetMetricData` |
119
119
  | `diagnose_application_fleet` | Fleet state, capacity, scaling activity, fleet errors | `appstream:DescribeFleets`, `appstream:ListAssociatedStacks`, `cloudwatch:GetMetricData`, `application-autoscaling:DescribeScalingActivities` |
120
- | `check_directory_health` | Shared dependency: directory reachability/registration (skips `ds` for WorkSpaces-managed `wsd-` directories) | `ds:DescribeDirectories`, `workspaces:DescribeWorkspaceDirectories` |
120
+ | `check_directory_health` | Shared dependency: directory reachability/registration + registration properties (target OU, security group, flags); skips `ds` for WorkSpaces-managed `wsd-` directories | `ds:DescribeDirectories`, `workspaces:DescribeWorkspaceDirectories` |
121
121
 
122
122
  **Cost, utilization & performance**
123
123
  | Tool | Purpose | IAM actions |
@@ -134,8 +134,8 @@ and return a synthesized result, not raw API passthroughs.
134
134
  **Secure Browser**
135
135
  | Tool | Purpose | IAM actions |
136
136
  |---|---|---|
137
- | `get_secure_browser_portal_details` | Portal config + associated settings (browser/network/user/IP-access) | `workspaces-web:GetPortal`, `workspaces-web:List*`, `workspaces-web:Get*Settings` |
138
- | `get_secure_browser_portal_usage` | Portal session/usage metrics | `workspaces-web:ListPortals`, `cloudwatch:GetMetricData` |
137
+ | `get_secure_browser_portal_details` | Portal config + associated settings (browser/network/user/IP-access) + resolved data-protection redaction config | `workspaces-web:GetPortal`, `workspaces-web:List*`, `workspaces-web:Get*Settings`, `workspaces-web:GetDataProtectionSettings` |
138
+ | `get_secure_browser_portal_usage` | Current active sessions (live, `ListSessions`) + historic CloudWatch metrics | `workspaces-web:ListPortals`, `workspaces-web:ListSessions`, `cloudwatch:GetMetricData` |
139
139
 
140
140
  **Reporting & audit**
141
141
  | Tool | Purpose | IAM actions |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workspaces-euc-mcp-server
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: MCP server for administering the Amazon WorkSpaces family of End User Computing services (Personal, Pools, Applications, Secure Browser, Core).
5
5
  Project-URL: Homepage, https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp
6
6
  Project-URL: Repository, https://github.com/bengroeneveldsg/aws-workspaces-euc-mcp
@@ -81,7 +81,7 @@ audit, and governance tools:
81
81
  | `diagnose_application_fleet` | A WorkSpaces Applications fleet's health and capacity — fleet state, fleet errors, compute capacity, auto-scaling activity, and insufficient-capacity errors. |
82
82
  | `diagnose_pool` | A WorkSpaces Pool's health — state, pool errors, user-session capacity, backing directory health, and session-utilization. |
83
83
  | `get_application_fleet_usage` | A WorkSpaces Applications fleet's **usage history** — AWS/AppStream capacity/utilization time-series over a window, with a plain-language summary (e.g. idle running capacity) (Tier 0). |
84
- | `check_directory_health` | Registration state and AWS Directory Service stage for one or all WorkSpaces-registered directories. |
84
+ | `check_directory_health` | Registration state, AWS Directory Service stage, and **registration properties** (target **OU**, custom security group, local-admin / internet-access / maintenance-mode flags) for one or all WorkSpaces-registered directories. |
85
85
  | `analyze_workspace_utilization` | Classifies WorkSpaces Personal desktops as unused / idle / active from the `UserConnected` metric (Tier 1). |
86
86
  | `recommend_running_mode` | Flags AlwaysOn desktops with low usage as AutoStop candidates, with an **estimated $/mo saving** where the bundle price can be matched (Tier 1). |
87
87
  | `get_workspace_performance` | Native CPU / memory / disk / GPU / latency / uptime metrics per desktop from `AWS/WorkSpaces` — no CloudWatch agent (Tier 0). |
@@ -94,8 +94,8 @@ audit, and governance tools:
94
94
  | `audit_application_images` | Audits WorkSpaces Applications (AppStream 2.0) **images and image builders** — flags stale base images (unpatched OS), pinned/old AppStream agents, non-AVAILABLE/errored images, SHARED cross-account visibility, and **image builders left RUNNING** (cost + admin surface) (Tier 0). |
95
95
  | `get_euc_audit_trail` | **"Who changed what"** — recent EUC management events from CloudTrail (last 90 days, no trail required) across all services; mutations-only by default, flags destructive actions and errors (e.g. AccessDenied) (Tier 0). |
96
96
  | `get_euc_service_quotas` | **Service-quota limits + usage headroom** per EUC service; pairs limits with current usage (where AWS publishes a usage metric) to flag quotas approaching their limit — capacity planning (Tier 0). |
97
- | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, and attached policies (Tier 0). |
98
- | `get_secure_browser_portal_usage` | A Secure Browser portal's `AWS/WorkSpacesWeb` session metrics over a window (empty until the portal has sessions; Session Logger gives detail) (Tier 0). |
97
+ | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, attached policies, and — when configured — the **data-protection redaction config** (which built-in/custom patterns are redacted, confidence level, enforced/exempt URLs) (Tier 0). |
98
+ | `get_secure_browser_portal_usage` | A Secure Browser portal's **current active sessions** (live, via `ListSessions` — same as the console) plus **historic** `AWS/WorkSpacesWeb` metrics over a window (CloudWatch is historic-only; idle portals publish none) (Tier 0). |
99
99
  | `list_unused_resources` | Unused WorkSpaces desktops and stopped/zero-capacity fleets worth reclaiming (Tier 0). |
100
100
 
101
101
  Cost/utilization tools need the **Tier 1** IAM policy ([`iam/tier1-cost.json`](iam/tier1-cost.json));
@@ -51,7 +51,7 @@ audit, and governance tools:
51
51
  | `diagnose_application_fleet` | A WorkSpaces Applications fleet's health and capacity — fleet state, fleet errors, compute capacity, auto-scaling activity, and insufficient-capacity errors. |
52
52
  | `diagnose_pool` | A WorkSpaces Pool's health — state, pool errors, user-session capacity, backing directory health, and session-utilization. |
53
53
  | `get_application_fleet_usage` | A WorkSpaces Applications fleet's **usage history** — AWS/AppStream capacity/utilization time-series over a window, with a plain-language summary (e.g. idle running capacity) (Tier 0). |
54
- | `check_directory_health` | Registration state and AWS Directory Service stage for one or all WorkSpaces-registered directories. |
54
+ | `check_directory_health` | Registration state, AWS Directory Service stage, and **registration properties** (target **OU**, custom security group, local-admin / internet-access / maintenance-mode flags) for one or all WorkSpaces-registered directories. |
55
55
  | `analyze_workspace_utilization` | Classifies WorkSpaces Personal desktops as unused / idle / active from the `UserConnected` metric (Tier 1). |
56
56
  | `recommend_running_mode` | Flags AlwaysOn desktops with low usage as AutoStop candidates, with an **estimated $/mo saving** where the bundle price can be matched (Tier 1). |
57
57
  | `get_workspace_performance` | Native CPU / memory / disk / GPU / latency / uptime metrics per desktop from `AWS/WorkSpaces` — no CloudWatch agent (Tier 0). |
@@ -64,8 +64,8 @@ audit, and governance tools:
64
64
  | `audit_application_images` | Audits WorkSpaces Applications (AppStream 2.0) **images and image builders** — flags stale base images (unpatched OS), pinned/old AppStream agents, non-AVAILABLE/errored images, SHARED cross-account visibility, and **image builders left RUNNING** (cost + admin surface) (Tier 0). |
65
65
  | `get_euc_audit_trail` | **"Who changed what"** — recent EUC management events from CloudTrail (last 90 days, no trail required) across all services; mutations-only by default, flags destructive actions and errors (e.g. AccessDenied) (Tier 0). |
66
66
  | `get_euc_service_quotas` | **Service-quota limits + usage headroom** per EUC service; pairs limits with current usage (where AWS publishes a usage metric) to flag quotas approaching their limit — capacity planning (Tier 0). |
67
- | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, and attached policies (Tier 0). |
68
- | `get_secure_browser_portal_usage` | A Secure Browser portal's `AWS/WorkSpacesWeb` session metrics over a window (empty until the portal has sessions; Session Logger gives detail) (Tier 0). |
67
+ | `get_secure_browser_portal_details` | Resolves a Secure Browser portal's user settings (clipboard/print/download controls + timeouts), network, attached policies, and — when configured — the **data-protection redaction config** (which built-in/custom patterns are redacted, confidence level, enforced/exempt URLs) (Tier 0). |
68
+ | `get_secure_browser_portal_usage` | A Secure Browser portal's **current active sessions** (live, via `ListSessions` — same as the console) plus **historic** `AWS/WorkSpacesWeb` metrics over a window (CloudWatch is historic-only; idle portals publish none) (Tier 0). |
69
69
  | `list_unused_resources` | Unused WorkSpaces desktops and stopped/zero-capacity fleets worth reclaiming (Tier 0). |
70
70
 
71
71
  Cost/utilization tools need the **Tier 1** IAM policy ([`iam/tier1-cost.json`](iam/tier1-cost.json));
@@ -39,7 +39,9 @@
39
39
  "workspaces-web:ListUserSettings",
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
- "workspaces-web:GetNetworkSettings"
42
+ "workspaces-web:GetNetworkSettings",
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
43
45
  ],
44
46
  "Resource": "*"
45
47
  },
@@ -39,7 +39,9 @@
39
39
  "workspaces-web:ListUserSettings",
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
- "workspaces-web:GetNetworkSettings"
42
+ "workspaces-web:GetNetworkSettings",
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
43
45
  ],
44
46
  "Resource": "*"
45
47
  },
@@ -39,7 +39,9 @@
39
39
  "workspaces-web:ListUserSettings",
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
- "workspaces-web:GetNetworkSettings"
42
+ "workspaces-web:GetNetworkSettings",
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
43
45
  ],
44
46
  "Resource": "*"
45
47
  },
@@ -39,7 +39,9 @@
39
39
  "workspaces-web:ListUserSettings",
40
40
  "workspaces-web:ListNetworkSettings",
41
41
  "workspaces-web:GetUserSettings",
42
- "workspaces-web:GetNetworkSettings"
42
+ "workspaces-web:GetNetworkSettings",
43
+ "workspaces-web:GetDataProtectionSettings",
44
+ "workspaces-web:ListSessions"
43
45
  ],
44
46
  "Resource": "*"
45
47
  },
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "workspaces-euc-mcp-server"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  description = "MCP server for administering the Amazon WorkSpaces family of End User Computing services (Personal, Pools, Applications, Secure Browser, Core)."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -161,6 +161,45 @@ def test_directory_health_skips_ds_for_workspaces_managed_directory():
161
161
  assert any("WorkSpaces-managed" in f.title for f in d.findings)
162
162
 
163
163
 
164
+ def test_directory_health_surfaces_registration_ou_and_properties():
165
+ # The registration OU (WorkspaceCreationProperties.DefaultOu) and related properties must be
166
+ # exposed in the diagnosis signals.
167
+ workspaces = types.SimpleNamespace(
168
+ describe_workspace_directories=lambda **_: {
169
+ "Directories": [
170
+ {
171
+ "DirectoryId": "d-0123456789",
172
+ "State": "REGISTERED",
173
+ "DirectoryType": "AD_CONNECTOR",
174
+ "WorkspaceType": "PERSONAL",
175
+ "WorkspaceCreationProperties": {
176
+ "DefaultOu": "OU=AmazonWorkspaces,OU=Singapore,DC=bg,DC=local",
177
+ "CustomSecurityGroupId": "sg-0abc",
178
+ "UserEnabledAsLocalAdministrator": True,
179
+ "EnableInternetAccess": False,
180
+ "EnableMaintenanceMode": True,
181
+ },
182
+ }
183
+ ]
184
+ },
185
+ )
186
+ ds = types.SimpleNamespace(
187
+ describe_directories=lambda **_: {
188
+ "DirectoryDescriptions": [{"DirectoryId": "d-0123456789", "Stage": "Active"}]
189
+ },
190
+ )
191
+ factory = FakeFactory({consts.WORKSPACES_API: workspaces, consts.DIRECTORY_API: ds})
192
+
193
+ report = diagnostics.check_directory_health_core(factory, "d-0123456789", "us-east-1")
194
+
195
+ sig = report.directories[0].signals
196
+ assert sig["default_ou"] == "OU=AmazonWorkspaces,OU=Singapore,DC=bg,DC=local"
197
+ assert sig["directory_type"] == "AD_CONNECTOR"
198
+ assert sig["custom_security_group_id"] == "sg-0abc"
199
+ assert sig["user_enabled_as_local_administrator"] is True
200
+ assert sig["enable_maintenance_mode"] is True
201
+
202
+
164
203
  def test_directory_health_flags_impaired_stage():
165
204
  workspaces = types.SimpleNamespace(
166
205
  describe_workspace_directories=lambda **_: {
@@ -0,0 +1,144 @@
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
+ """Tests for the WorkSpaces Secure Browser tools."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import types
9
+
10
+ from workspaces_euc_mcp_server import consts
11
+ from workspaces_euc_mcp_server.tools import secure_browser
12
+
13
+
14
+ class FakeFactory:
15
+ region = "us-east-1"
16
+
17
+ def __init__(self, clients: dict[str, object]) -> None:
18
+ self._clients = clients
19
+
20
+ def client(self, service_name: str, region: str | None = None):
21
+ if service_name not in self._clients:
22
+ raise AssertionError(f"unexpected client requested: {service_name}")
23
+ return self._clients[service_name]
24
+
25
+
26
+ def test_portal_details_resolves_settings():
27
+ web = types.SimpleNamespace(
28
+ get_portal=lambda **_: {
29
+ "portal": {
30
+ "portalArn": "arn:portal/1",
31
+ "displayName": "P1",
32
+ "authenticationType": "Standard",
33
+ "portalStatus": "Active",
34
+ "userSettingsArn": "arn:us/1",
35
+ "networkSettingsArn": "arn:ns/1",
36
+ "browserSettingsArn": "arn:bs/1",
37
+ }
38
+ },
39
+ get_user_settings=lambda **_: {
40
+ "userSettings": {
41
+ "copyAllowed": "Disabled",
42
+ "downloadAllowed": "Enabled",
43
+ "printAllowed": "Disabled",
44
+ "idleDisconnectTimeoutInMinutes": 15,
45
+ "associatedPortalArns": ["arn:portal/1"], # should be dropped
46
+ }
47
+ },
48
+ get_network_settings=lambda **_: {
49
+ "networkSettings": {
50
+ "vpcId": "vpc-1",
51
+ "subnetIds": ["s-1"],
52
+ "securityGroupIds": ["sg-1"],
53
+ }
54
+ },
55
+ )
56
+ factory = FakeFactory({consts.SECURE_BROWSER_API: web})
57
+
58
+ d = secure_browser.get_secure_browser_portal_details_core(factory, "arn:portal/1", "us-east-1")
59
+
60
+ assert d.display_name == "P1"
61
+ assert d.user_settings["downloadAllowed"] == "Enabled"
62
+ assert "associatedPortalArns" not in d.user_settings # bulky/identifying field dropped
63
+ assert d.network["vpcId"] == "vpc-1"
64
+ assert d.has_browser_policy is True
65
+ assert d.has_data_protection is False
66
+
67
+
68
+ def test_portal_details_resolves_data_protection_config():
69
+ web = types.SimpleNamespace(
70
+ get_portal=lambda **_: {
71
+ "portal": {
72
+ "portalArn": "arn:portal/2",
73
+ "displayName": "Okta",
74
+ "dataProtectionSettingsArn": "arn:dp/1",
75
+ }
76
+ },
77
+ get_data_protection_settings=lambda **_: {
78
+ "dataProtectionSettings": {
79
+ "displayName": "ip-address-dp",
80
+ "inlineRedactionConfiguration": {
81
+ "inlineRedactionPatterns": [
82
+ {"builtInPatternId": "ipAddr"},
83
+ {"builtInPatternId": "macAddr"},
84
+ {
85
+ "customPattern": {
86
+ "patternName": "EmployeeId",
87
+ "keywordRegex": "EMP-\\d+",
88
+ }
89
+ },
90
+ ],
91
+ "globalEnforcedUrls": ["*"],
92
+ "globalConfidenceLevel": 2,
93
+ },
94
+ }
95
+ },
96
+ )
97
+ factory = FakeFactory({consts.SECURE_BROWSER_API: web})
98
+
99
+ d = secure_browser.get_secure_browser_portal_details_core(factory, "arn:portal/2", "us-east-1")
100
+
101
+ assert d.has_data_protection is True
102
+ dp = d.data_protection
103
+ assert dp["display_name"] == "ip-address-dp"
104
+ assert dp["redacted_pattern_count"] == 3
105
+ assert dp["builtin_patterns"] == ["ipAddr", "macAddr"]
106
+ assert dp["custom_patterns"][0]["name"] == "EmployeeId"
107
+ assert dp["global_confidence_level"] == 2
108
+ assert dp["global_enforced_urls"] == ["*"]
109
+
110
+
111
+ def test_portal_usage_returns_live_active_sessions_and_historic():
112
+ # Active sessions must come LIVE from ListSessions (status=Active), not from CloudWatch.
113
+ captured = {}
114
+
115
+ def list_sessions(**kwargs):
116
+ captured.update(kwargs)
117
+ return {
118
+ "sessions": [
119
+ {"sessionId": "s-1", "username": "alice", "status": "Active"},
120
+ {"sessionId": "s-2", "username": "bob", "status": "Active"},
121
+ ]
122
+ }
123
+
124
+ web = types.SimpleNamespace(list_sessions=list_sessions)
125
+ cw = types.SimpleNamespace(get_metric_data=lambda **_: {"MetricDataResults": []}) # no historic
126
+ factory = FakeFactory({consts.SECURE_BROWSER_API: web, consts.CLOUDWATCH_API: cw})
127
+
128
+ usage = secure_browser.get_secure_browser_portal_usage_core(
129
+ factory, "arn:aws:workspaces-web:r:a:portal/abc123", "us-east-1"
130
+ )
131
+
132
+ # Queried ListSessions for the portal id with status=Active.
133
+ assert captured["portalId"] == "abc123"
134
+ assert captured["status"] == "Active"
135
+ # Live active sessions populated; CloudWatch reserved for (empty) historic.
136
+ assert usage.active_session_count == 2
137
+ assert {s.username for s in usage.active_sessions} == {"alice", "bob"}
138
+ assert usage.historic_metrics == {}
139
+ assert "active session(s) right now" in (usage.summary or "")
140
+
141
+
142
+ def test_portal_id_extracted_from_arn():
143
+ assert secure_browser._portal_id("arn:aws:workspaces-web:r:a:portal/abc123") == "abc123"
144
+ assert secure_browser._portal_id("abc123") == "abc123"
@@ -3,4 +3,4 @@
3
3
  # A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0
4
4
  """MCP server for administering the Amazon WorkSpaces End User Computing portfolio."""
5
5
 
6
- __version__ = "0.1.5"
6
+ __version__ = "0.1.7"
@@ -156,6 +156,42 @@ class SecureBrowserPortalDetails(BaseModel):
156
156
  network: dict[str, object] = Field(default_factory=dict)
157
157
  has_browser_policy: bool = False
158
158
  has_data_protection: bool = False
159
+ data_protection: dict[str, object] = Field(
160
+ default_factory=dict,
161
+ description=(
162
+ "Resolved inline-redaction configuration when data protection is attached: redacted "
163
+ "patterns (built-in + custom), global confidence level, and enforced/exempt URLs."
164
+ ),
165
+ )
166
+ errors: list[ServiceError] = Field(default_factory=list)
167
+
168
+
169
+ class SecureBrowserSession(BaseModel):
170
+ """A single Secure Browser portal session (from ListSessions)."""
171
+
172
+ session_id: str
173
+ username: str | None = None
174
+ status: str | None = None
175
+ start_time: str | None = None
176
+
177
+
178
+ class SecureBrowserPortalUsage(BaseModel):
179
+ """Live active sessions (ListSessions) plus historic metrics (CloudWatch) for a portal."""
180
+
181
+ portal: str
182
+ active_session_count: int = 0
183
+ active_sessions: list[SecureBrowserSession] = Field(
184
+ default_factory=list,
185
+ description="Sessions currently Active, retrieved live from ListSessions (like the "
186
+ "console).",
187
+ )
188
+ lookback_days: int
189
+ period_hours: int
190
+ historic_metrics: dict[str, FleetMetricSeries] = Field(
191
+ default_factory=dict,
192
+ description="Historic session metrics from CloudWatch (AWS/WorkSpacesWeb) over the window.",
193
+ )
194
+ summary: str | None = None
159
195
  errors: list[ServiceError] = Field(default_factory=list)
160
196
 
161
197
 
@@ -279,9 +279,22 @@ def _diagnose_directory_into(
279
279
  )
280
280
  dirs = (reg or {}).get("Directories", [])
281
281
  if dirs:
282
- reg_state = dirs[0].get("State", "UNKNOWN")
282
+ d0 = dirs[0]
283
+ reg_state = d0.get("State", "UNKNOWN")
283
284
  if signals is not None:
284
285
  signals[f"{signals_prefix}registration_state"] = reg_state
286
+ signals[f"{signals_prefix}directory_type"] = d0.get("DirectoryType")
287
+ signals[f"{signals_prefix}workspace_type"] = d0.get("WorkspaceType")
288
+ # Registration-level WorkspaceCreationProperties — notably the target OU. Only AD-backed
289
+ # directories carry a DefaultOu; WorkSpaces-managed (Entra/internal) ones return None.
290
+ wcp = d0.get("WorkspaceCreationProperties") or {}
291
+ signals[f"{signals_prefix}default_ou"] = wcp.get("DefaultOu")
292
+ signals[f"{signals_prefix}custom_security_group_id"] = wcp.get("CustomSecurityGroupId")
293
+ signals[f"{signals_prefix}user_enabled_as_local_administrator"] = wcp.get(
294
+ "UserEnabledAsLocalAdministrator"
295
+ )
296
+ signals[f"{signals_prefix}enable_internet_access"] = wcp.get("EnableInternetAccess")
297
+ signals[f"{signals_prefix}enable_maintenance_mode"] = wcp.get("EnableMaintenanceMode")
285
298
  if reg_state != "REGISTERED":
286
299
  findings.append(
287
300
  Finding(
@@ -18,8 +18,13 @@ from typing import Any
18
18
 
19
19
  from .. import consts
20
20
  from ..clients import ClientFactory
21
- from ..models import SecureBrowserPortalDetails, ServiceError, UsageHistory
22
- from ._common import read_only, try_call
21
+ from ..models import (
22
+ SecureBrowserPortalDetails,
23
+ SecureBrowserPortalUsage,
24
+ SecureBrowserSession,
25
+ ServiceError,
26
+ )
27
+ from ._common import paginate, read_only, try_call
23
28
  from .performance import _fetch_metric_series
24
29
 
25
30
 
@@ -78,6 +83,19 @@ def get_secure_browser_portal_details_core(
78
83
  if key in (ns or {}):
79
84
  network[key] = ns[key]
80
85
 
86
+ data_protection: dict[str, object] = {}
87
+ if portal.get("dataProtectionSettingsArn"):
88
+ dps = try_call(
89
+ errors,
90
+ consts.PRODUCT_SECURE_BROWSER,
91
+ "GetDataProtectionSettings",
92
+ lambda: web.get_data_protection_settings(
93
+ dataProtectionSettingsArn=portal["dataProtectionSettingsArn"]
94
+ ).get("dataProtectionSettings", {}),
95
+ default={},
96
+ )
97
+ data_protection = _summarize_data_protection(dps or {})
98
+
81
99
  return SecureBrowserPortalDetails(
82
100
  portal_arn=portal_arn,
83
101
  display_name=portal.get("displayName"),
@@ -87,23 +105,93 @@ def get_secure_browser_portal_details_core(
87
105
  network=network,
88
106
  has_browser_policy=bool(portal.get("browserSettingsArn")),
89
107
  has_data_protection=bool(portal.get("dataProtectionSettingsArn")),
108
+ data_protection=data_protection,
90
109
  errors=errors,
91
110
  )
92
111
 
93
112
 
113
+ def _summarize_data_protection(dps: dict[str, Any]) -> dict[str, Any]:
114
+ """Reduce a data-protection settings object to the policy-relevant redaction configuration."""
115
+ inline: dict[str, Any] = dps.get("inlineRedactionConfiguration") or {}
116
+ patterns: list[dict[str, Any]] = inline.get("inlineRedactionPatterns") or []
117
+ builtin: list[str] = []
118
+ custom: list[dict[str, Any]] = []
119
+ for p in patterns:
120
+ if p.get("builtInPatternId"):
121
+ builtin.append(p["builtInPatternId"])
122
+ elif p.get("customPattern"):
123
+ cp = p["customPattern"]
124
+ custom.append(
125
+ {
126
+ "name": cp.get("patternName"),
127
+ "description": cp.get("patternDescription"),
128
+ "keyword_regex": cp.get("keywordRegex"),
129
+ }
130
+ )
131
+ return {
132
+ "display_name": dps.get("displayName"),
133
+ "redacted_pattern_count": len(patterns),
134
+ "builtin_patterns": builtin,
135
+ "custom_patterns": custom,
136
+ "global_confidence_level": inline.get("globalConfidenceLevel"),
137
+ "global_enforced_urls": inline.get("globalEnforcedUrls"),
138
+ "global_exempt_urls": inline.get("globalExemptUrls"),
139
+ }
140
+
141
+
94
142
  def _portal_id(portal: str) -> str:
95
143
  """Accept a portal ARN or id; the CloudWatch dimension uses the id (last ARN segment)."""
96
144
  return portal.rsplit("/", 1)[-1] if "/" in portal else portal
97
145
 
98
146
 
147
+ def _list_active_sessions(
148
+ web: Any, portal_id: str, errors: list[ServiceError]
149
+ ) -> list[SecureBrowserSession]:
150
+ """Current Active sessions for a portal, live from ListSessions (what the console shows)."""
151
+ raw = try_call(
152
+ errors,
153
+ consts.PRODUCT_SECURE_BROWSER,
154
+ "ListSessions",
155
+ lambda: paginate(
156
+ web.list_sessions,
157
+ "sessions",
158
+ pagination_in="nextToken",
159
+ pagination_out="nextToken",
160
+ portalId=portal_id,
161
+ status="Active",
162
+ ),
163
+ default=[],
164
+ )
165
+ sessions: list[SecureBrowserSession] = []
166
+ for s in raw or []:
167
+ start = s.get("startTime")
168
+ sessions.append(
169
+ SecureBrowserSession(
170
+ session_id=s.get("sessionId", ""),
171
+ username=s.get("username"),
172
+ status=s.get("status"),
173
+ start_time=start.isoformat() if hasattr(start, "isoformat") else None,
174
+ )
175
+ )
176
+ return sessions
177
+
178
+
99
179
  def get_secure_browser_portal_usage_core(
100
180
  factory: ClientFactory,
101
181
  portal: str,
102
182
  region: str | None,
103
183
  lookback_days: int = 7,
104
184
  period_hours: int = 24,
105
- ) -> UsageHistory:
185
+ ) -> SecureBrowserPortalUsage:
106
186
  errors: list[ServiceError] = []
187
+ portal_id = _portal_id(portal)
188
+ web = factory.client(consts.SECURE_BROWSER_API, region=region)
189
+
190
+ # LIVE: current active sessions come from ListSessions (the real-time source the console uses),
191
+ # NOT from CloudWatch.
192
+ active = _list_active_sessions(web, portal_id, errors)
193
+
194
+ # HISTORIC: CloudWatch (AWS/WorkSpacesWeb) over the window — only meaningful for past activity.
107
195
  cloudwatch = factory.client(consts.CLOUDWATCH_API, region=region)
108
196
  metrics = try_call(
109
197
  errors,
@@ -113,26 +201,30 @@ def get_secure_browser_portal_usage_core(
113
201
  cloudwatch,
114
202
  consts.SECURE_BROWSER_NAMESPACE,
115
203
  consts.SECURE_BROWSER_PORTAL_DIMENSION,
116
- _portal_id(portal),
204
+ portal_id,
117
205
  consts.SECURE_BROWSER_SESSION_METRICS,
118
206
  lookback_days,
119
207
  period_hours,
120
208
  ),
121
209
  default={},
122
210
  )
211
+
212
+ hist = (
213
+ f"historic metrics over {lookback_days}d available (see historic_metrics)"
214
+ if metrics
215
+ else f"no historic session metrics in the last {lookback_days}d (idle portals publish "
216
+ "none to CloudWatch; enable the portal's Session Logger for detail)"
217
+ )
123
218
  summary = (
124
- "No session metrics in the window. Secure Browser only emits CloudWatch metrics when "
125
- "sessions occur (idle portals publish nothing); for detailed usage enable the portal's "
126
- "Session Logger."
127
- if not metrics
128
- else f"Session metrics over {lookback_days}d: see series."
219
+ f"{len(active)} active session(s) right now (live via ListSessions). Historic: {hist}."
129
220
  )
130
- return UsageHistory(
131
- target_type=consts.PRODUCT_SECURE_BROWSER,
132
- target_id=portal,
221
+ return SecureBrowserPortalUsage(
222
+ portal=portal,
223
+ active_session_count=len(active),
224
+ active_sessions=active,
133
225
  lookback_days=lookback_days,
134
226
  period_hours=period_hours,
135
- metrics=metrics or {},
227
+ historic_metrics=metrics or {},
136
228
  summary=summary,
137
229
  errors=errors,
138
230
  )
@@ -147,8 +239,10 @@ def register(mcp: Any, factory: ClientFactory) -> None:
147
239
  """Resolve a WorkSpaces Secure Browser portal's settings (security-relevant).
148
240
 
149
241
  Returns the portal's user settings (clipboard copy/paste, file download/upload, print
150
- controls + timeouts), network (VPC/subnets/security groups), and whether a browser policy
151
- and data-protection settings are attached. Read-only.
242
+ controls + timeouts), network (VPC/subnets/security groups), whether a browser policy is
243
+ attached, and — when data protection is configured the resolved inline-redaction config
244
+ (which built-in/custom patterns are redacted, global confidence, enforced/exempt URLs).
245
+ Read-only.
152
246
 
153
247
  Args:
154
248
  portal_arn: The portal ARN (from get_euc_inventory_summary / generate_inventory_report).
@@ -165,10 +259,12 @@ def register(mcp: Any, factory: ClientFactory) -> None:
165
259
  lookback_days: int = 7,
166
260
  period_hours: int = 24,
167
261
  ) -> dict[str, Any]:
168
- """Get a Secure Browser portal's session metrics (AWS/WorkSpacesWeb) over a window.
262
+ """Get a Secure Browser portal's CURRENT active sessions plus historic session metrics.
169
263
 
170
- NOTE: Secure Browser only emits these metrics when sessions occur, so idle portals return
171
- nothing; richer per-session data is available via the portal's Session Logger. Read-only.
264
+ Active sessions are retrieved **live** from ListSessions (the same source as the console's
265
+ active-sessions view). Historic usage comes from CloudWatch (AWS/WorkSpacesWeb) over the
266
+ window — CloudWatch is only meaningful for *past* activity, and idle portals publish none.
267
+ Read-only.
172
268
 
173
269
  Args:
174
270
  portal: The portal id or ARN.
@@ -1,85 +0,0 @@
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
- """Tests for the WorkSpaces Secure Browser tools."""
5
-
6
- from __future__ import annotations
7
-
8
- import types
9
-
10
- from workspaces_euc_mcp_server import consts
11
- from workspaces_euc_mcp_server.tools import secure_browser
12
-
13
-
14
- class FakeFactory:
15
- region = "us-east-1"
16
-
17
- def __init__(self, clients: dict[str, object]) -> None:
18
- self._clients = clients
19
-
20
- def client(self, service_name: str, region: str | None = None):
21
- if service_name not in self._clients:
22
- raise AssertionError(f"unexpected client requested: {service_name}")
23
- return self._clients[service_name]
24
-
25
-
26
- def test_portal_details_resolves_settings():
27
- web = types.SimpleNamespace(
28
- get_portal=lambda **_: {
29
- "portal": {
30
- "portalArn": "arn:portal/1",
31
- "displayName": "P1",
32
- "authenticationType": "Standard",
33
- "portalStatus": "Active",
34
- "userSettingsArn": "arn:us/1",
35
- "networkSettingsArn": "arn:ns/1",
36
- "browserSettingsArn": "arn:bs/1",
37
- }
38
- },
39
- get_user_settings=lambda **_: {
40
- "userSettings": {
41
- "copyAllowed": "Disabled",
42
- "downloadAllowed": "Enabled",
43
- "printAllowed": "Disabled",
44
- "idleDisconnectTimeoutInMinutes": 15,
45
- "associatedPortalArns": ["arn:portal/1"], # should be dropped
46
- }
47
- },
48
- get_network_settings=lambda **_: {
49
- "networkSettings": {
50
- "vpcId": "vpc-1",
51
- "subnetIds": ["s-1"],
52
- "securityGroupIds": ["sg-1"],
53
- }
54
- },
55
- )
56
- factory = FakeFactory({consts.SECURE_BROWSER_API: web})
57
-
58
- d = secure_browser.get_secure_browser_portal_details_core(factory, "arn:portal/1", "us-east-1")
59
-
60
- assert d.display_name == "P1"
61
- assert d.user_settings["downloadAllowed"] == "Enabled"
62
- assert "associatedPortalArns" not in d.user_settings # bulky/identifying field dropped
63
- assert d.network["vpcId"] == "vpc-1"
64
- assert d.has_browser_policy is True
65
- assert d.has_data_protection is False
66
-
67
-
68
- def test_portal_usage_empty_explains_session_model():
69
- cw = types.SimpleNamespace(
70
- get_metric_data=lambda **_: {"MetricDataResults": []} # no data
71
- )
72
- factory = FakeFactory({consts.CLOUDWATCH_API: cw})
73
-
74
- usage = secure_browser.get_secure_browser_portal_usage_core(
75
- factory, "arn:aws:workspaces-web:r:a:portal/abc123", "us-east-1"
76
- )
77
-
78
- assert usage.target_type == consts.PRODUCT_SECURE_BROWSER
79
- assert usage.target_id.endswith("portal/abc123")
80
- assert "Session Logger" in (usage.summary or "")
81
-
82
-
83
- def test_portal_id_extracted_from_arn():
84
- assert secure_browser._portal_id("arn:aws:workspaces-web:r:a:portal/abc123") == "abc123"
85
- assert secure_browser._portal_id("abc123") == "abc123"