Repository: cloud-custodian/cloud-custodian

Issue:
aws - workspaces web support
### Describe the feature

Add support for aws workspaces web

### Extra information or context

_No response_


Discussion:

Resolving patch:
diff --git a/c7n/resources/resource_map.py b/c7n/resources/resource_map.py
--- a/c7n/resources/resource_map.py
+++ b/c7n/resources/resource_map.py
@@ -260,6 +260,7 @@
   "aws.workspaces": "c7n.resources.workspaces.Workspace",
   "aws.workspaces-directory": "c7n.resources.workspaces.WorkspaceDirectory",
   "aws.workspaces-image": "c7n.resources.workspaces.WorkspaceImage",
+  "aws.workspaces-web": "c7n.resources.workspace.WorkspacesWeb",
   "aws.xray-group": "c7n.resources.xray.XRayGroup",
   "aws.xray-rule": "c7n.resources.xray.XRaySamplingRule"
 }
diff --git a/c7n/resources/workspaces.py b/c7n/resources/workspaces.py
--- a/c7n/resources/workspaces.py
+++ b/c7n/resources/workspaces.py
@@ -8,7 +8,7 @@
 from c7n.filters.kms import KmsRelatedFilter
 from c7n.manager import resources
 from c7n.query import QueryResourceManager, TypeInfo, DescribeSource, ConfigSource
-from c7n.tags import universal_augment
+from c7n.tags import universal_augment, Tag, RemoveTag
 from c7n.exceptions import PolicyValidationError, PolicyExecutionError
 from c7n.utils import get_retry, local_session, type_schema, chunks, jmespath_search
 from c7n.filters.iamaccess import CrossAccountAccessFilter
@@ -458,3 +458,108 @@ def process(self, directories):
                 'The following directories must be removed from WorkSpaces'
                 'and cannot be deregistered: %s ' % ''.join(map(str, exceptions))
             )
+
+@resources.register('workspaces-web')
+class WorkspacesWeb(QueryResourceManager):
+
+    class resource_type(TypeInfo):
+        service = 'workspaces-web'
+        enum_spec = ('list_portals', 'portals', None)
+        arn_type = 'portal'
+        name = 'displayName'
+        arn = id = "portalArn"
+
+    augment = universal_augment
+
+@WorkspacesWeb.action_registry.register('tag')
+class TagWorkspacesWebResource(Tag):
+    """Create tags on a Workspaces Web portal
+
+    :example:
+
+    .. code-block:: yaml
+
+        policies:
+            - name: tag-workspaces-web
+              resource: workspaces-web
+              actions:
+                - type: tag
+                  key: test-key
+                  value: test-value
+    """
+    permissions = ('workspaces-web:TagResource',)
+
+    def process_resource_set(self, client, resources, new_tags):
+        for r in resources:
+            client.tag_resource(resourceArn=r["portalArn"], tags=new_tags)
+
+@WorkspacesWeb.action_registry.register('remove-tag')
+class RemoveTagWorkspacesWebResource(RemoveTag):
+    """Remove tags from a Workspaces Web portal
+
+    :example:
+
+    .. code-block:: yaml
+
+        policies:
+            - name: remove-tag-workspaces-web
+              resource: workspaces-web
+              actions:
+                - type: remove-tag
+                  tags: ["tag-key"]
+    """
+    permissions = ('workspaces-web:UntagResource',)
+
+    def process_resource_set(self, client, resources, tags):
+        for r in resources:
+            client.untag_resource(resourceArn=r['portalArn'], tagKeys=tags)
+
+@WorkspacesWeb.action_registry.register('delete')
+class DeleteWorkspacesWeb(BaseAction):
+    """Delete a WorkSpaces Web portal
+
+    :example:
+
+    .. code-block:: yaml
+
+        policies:
+          - name: delete-workspaces-web
+            resource: workspaces-web
+            actions:
+              - type: delete
+    """
+    schema = type_schema('delete')
+    permissions = (
+        'workspaces-web:DeletePortal',
+        'workspaces-web:DisassociateNetworkSettings',
+        'workspaces-web:DisassociateBrowserSettings',
+        'workspaces-web:DisassociateUserSettings'
+    )
+
+    def process(self, resources):
+        client = local_session(self.manager.session_factory).client('workspaces-web')
+        for r in resources:
+            self.disassociate_settings(client, r, 'networkSettingsArn',
+                                       'disassociate_network_settings')
+            self.disassociate_settings(client, r, 'browserSettingsArn',
+                                       'disassociate_browser_settings')
+            self.disassociate_settings(client, r, 'userSettingsArn',
+                                       'disassociate_user_settings')
+            self.delete_portal(client, r)
+
+    def disassociate_settings(self, client, resource, setting_arn_key, disassociate_method_name):
+        setting_arn = resource.get(setting_arn_key)
+        if setting_arn:
+            disassociate_method = getattr(client, disassociate_method_name)
+            try:
+                disassociate_method(portalArn=resource["portalArn"])
+            except client.exceptions.ResourceNotFoundException:
+                pass
+            except Exception as e:
+                self.log.error(
+                    "Failed to disassociate %s for portal %s: %s",
+                    setting_arn_key, resource['portalArn'], str(e)
+                )
+
+    def delete_portal(self, client, resource):
+        client.delete_portal(portalArn=resource['portalArn'])


Test patch:
diff --git a/tests/data/placebo/test_workspaces_web_delete/tagging.GetResources_1.json b/tests/data/placebo/test_workspaces_web_delete/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/tagging.GetResources_1.json
@@ -0,0 +1,13 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+            {
+                "ResourceARN": "arn:aws:workspaces-web:us-east-1:644160558196:portal/86048a7f-ee77-4dff-99bb-145ba3d1ad45",
+                "Tags": []
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DeletePortal_1.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DeletePortal_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DeletePortal_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateBrowserSettings_1.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateBrowserSettings_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateBrowserSettings_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateNetworkSettings_1.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateNetworkSettings_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateNetworkSettings_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateUserSettings_1.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateUserSettings_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.DisassociateUserSettings_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_1.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_1.json
@@ -0,0 +1,30 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "portals": [
+            {
+                "authenticationType": "IAM_Identity_Center",
+                "browserSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:browserSettings/9db842bb-2e53-4707-9b22-c95596aaa2ee",
+                "browserType": "Chrome",
+                "creationDate": {
+                    "__class__": "datetime",
+                    "year": 2023,
+                    "month": 10,
+                    "day": 29,
+                    "hour": 12,
+                    "minute": 32,
+                    "second": 43,
+                    "microsecond": 993000
+                },
+                "displayName": "test",
+                "networkSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:networkSettings/41e1c85e-249a-4235-9d6e-68bd807d5523",
+                "portalArn": "arn:aws:workspaces-web:us-east-1:644160558196:portal/86048a7f-ee77-4dff-99bb-145ba3d1ad45",
+                "portalEndpoint": "86048a7f-ee77-4dff-99bb-145ba3d1ad45.workspaces-web.com",
+                "portalStatus": "Active",
+                "rendererType": "AppStream",
+                "userSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:userSettings/25f40942-7e45-4eaf-9b21-77d89e5559b5"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_2.json b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_delete/workspaces-web.ListPortals_2.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "portals": []
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_remove_tag/tagging.GetResources_1.json b/tests/data/placebo/test_workspaces_web_remove_tag/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_remove_tag/tagging.GetResources_1.json
@@ -0,0 +1,18 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [
+            {
+                "ResourceARN": "arn:aws:workspaces-web:us-east-1:644160558196:portal/86048a7f-ee77-4dff-99bb-145ba3d1ad45",
+                "Tags": [
+                    {
+                        "Key": "foo",
+                        "Value": "bar"
+                    }
+                ]
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListPortals_1.json b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListPortals_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListPortals_1.json
@@ -0,0 +1,30 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "portals": [
+            {
+                "authenticationType": "IAM_Identity_Center",
+                "browserSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:browserSettings/9db842bb-2e53-4707-9b22-c95596aaa2ee",
+                "browserType": "Chrome",
+                "creationDate": {
+                    "__class__": "datetime",
+                    "year": 2023,
+                    "month": 10,
+                    "day": 29,
+                    "hour": 12,
+                    "minute": 32,
+                    "second": 43,
+                    "microsecond": 993000
+                },
+                "displayName": "test",
+                "networkSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:networkSettings/41e1c85e-249a-4235-9d6e-68bd807d5523",
+                "portalArn": "arn:aws:workspaces-web:us-east-1:644160558196:portal/86048a7f-ee77-4dff-99bb-145ba3d1ad45",
+                "portalEndpoint": "86048a7f-ee77-4dff-99bb-145ba3d1ad45.workspaces-web.com",
+                "portalStatus": "Active",
+                "rendererType": "AppStream",
+                "userSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:userSettings/25f40942-7e45-4eaf-9b21-77d89e5559b5"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListTagsForResource_1.json b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListTagsForResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.ListTagsForResource_1.json
@@ -0,0 +1,7 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "tags": []
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.UntagResource_1.json b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.UntagResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_remove_tag/workspaces-web.UntagResource_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_tag/tagging.GetResources_1.json b/tests/data/placebo/test_workspaces_web_tag/tagging.GetResources_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_tag/tagging.GetResources_1.json
@@ -0,0 +1,8 @@
+{
+    "status_code": 200,
+    "data": {
+        "PaginationToken": "",
+        "ResourceTagMappingList": [],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListPortals_1.json b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListPortals_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListPortals_1.json
@@ -0,0 +1,30 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "portals": [
+            {
+                "authenticationType": "IAM_Identity_Center",
+                "browserSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:browserSettings/9db842bb-2e53-4707-9b22-c95596aaa2ee",
+                "browserType": "Chrome",
+                "creationDate": {
+                    "__class__": "datetime",
+                    "year": 2023,
+                    "month": 10,
+                    "day": 29,
+                    "hour": 12,
+                    "minute": 32,
+                    "second": 43,
+                    "microsecond": 993000
+                },
+                "displayName": "test",
+                "networkSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:networkSettings/41e1c85e-249a-4235-9d6e-68bd807d5523",
+                "portalArn": "arn:aws:workspaces-web:us-east-1:644160558196:portal/86048a7f-ee77-4dff-99bb-145ba3d1ad45",
+                "portalEndpoint": "86048a7f-ee77-4dff-99bb-145ba3d1ad45.workspaces-web.com",
+                "portalStatus": "Active",
+                "rendererType": "AppStream",
+                "userSettingsArn": "arn:aws:workspaces-web:us-east-1:644160558196:userSettings/25f40942-7e45-4eaf-9b21-77d89e5559b5"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListTagsForResource_1.json b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListTagsForResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.ListTagsForResource_1.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {},
+        "tags": [
+            {
+                "Key": "foo",
+                "Value": "bar"
+            }
+        ]
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_workspaces_web_tag/workspaces-web.TagResource_1.json b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.TagResource_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_workspaces_web_tag/workspaces-web.TagResource_1.json
@@ -0,0 +1,6 @@
+{
+    "status_code": 200,
+    "data": {
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_workspaces.py b/tests/test_workspaces.py
--- a/tests/test_workspaces.py
+++ b/tests/test_workspaces.py
@@ -383,3 +383,76 @@ def test_workspaces_directory_client_properties(self):
         cp = client.describe_client_properties(ResourceIds=['d-90675153fc'])
         self.assertEqual({'ReconnectEnabled': 'DISABLED'}, cp.get(
             'ClientPropertiesList')[0].get('ClientProperties'))
+
+class TestWorkspacesWeb(BaseTest):
+
+    def test_workspaces_web_tag(self):
+        session_factory = self.replay_flight_data('test_workspaces_web_tag')
+        p = self.load_policy(
+            {
+                'name': 'test-workspaces-web-tag',
+                'resource': 'workspaces-web',
+                'filters': [
+                    {
+                        'tag:foo': 'absent',
+                    }
+                ],
+                'actions': [
+                    {
+                        'type': 'tag',
+                        'tags': {'foo': 'bar'}
+                    }
+                ]
+            }, session_factory=session_factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        client = session_factory().client('workspaces-web')
+        tags = client.list_tags_for_resource(resourceArn=resources[0]["portalArn"])['tags']
+        self.assertEqual(len(tags), 1)
+        self.assertEqual(tags, [{'Key': 'foo', 'Value': 'bar'}])
+
+
+    def test_workspaces_web_remove_tag(self):
+        session_factory = self.replay_flight_data('test_workspaces_web_remove_tag')
+        p = self.load_policy(
+            {
+                'name': 'test-workspaces-web-remove-tag',
+                'resource': 'workspaces-web',
+                'filters': [
+                    {
+                        'tag:foo': 'present',
+                    }
+                ],
+                'actions': [
+                    {
+                        'type': 'remove-tag',
+                        'tags': ['foo']
+                    }
+                ]
+            }, session_factory=session_factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        client = session_factory().client('workspaces-web')
+        tags = client.list_tags_for_resource(resourceArn=resources[0]['portalArn'])['tags']
+        self.assertEqual(len(tags), 0)
+
+    def test_workspaces_web_delete(self):
+        session_factory = self.replay_flight_data('test_workspaces_web_delete')
+        p = self.load_policy(
+            {
+                'name': 'test-workspaces-web-delete',
+                'resource': 'workspaces-web',
+                'filters': [{'displayName': 'test'}],
+                'actions': [{'type': 'delete'}]
+            },
+            session_factory=session_factory
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        client = session_factory().client('workspaces-web')
+        if self.recording:
+            time.sleep(5)
+        portals = client.list_portals()['portals']
+        self.assertEqual(len(portals), 0)