1# Copyright The Cloud Custodian Authors.
2# SPDX-License-Identifier: Apache-2.0
3
4import itertools
5from c7n_gcp.filters.iampolicy import IamPolicyFilter
6
7from c7n_gcp.actions import SetIamPolicy, MethodAction
8from c7n_gcp.provider import resources
9from c7n_gcp.query import QueryResourceManager, TypeInfo
10
11from c7n.resolver import ValuesFrom
12from c7n.utils import type_schema, local_session
13from c7n.filters.core import ValueFilter, ListItemFilter
14from c7n.filters.missing import Missing
15
16from googleapiclient.errors import HttpError
17
18
19@resources.register('organization')
20class Organization(QueryResourceManager):
21 """GCP resource: https://cloud.google.com/resource-manager/reference/rest/v1/organizations
22 """
23 class resource_type(TypeInfo):
24 service = 'cloudresourcemanager'
25 version = 'v1'
26 component = 'organizations'
27 scope = 'global'
28 enum_spec = ('search', 'organizations[]', {'body': {}})
29 id = 'name'
30 name = 'displayName'
31 default_report_fields = [
32 "name", "displayName", "creationTime", "lifecycleState"]
33 asset_type = "cloudresourcemanager.googleapis.com/Organization"
34 scc_type = "google.cloud.resourcemanager.Organization"
35 perm_service = 'resourcemanager'
36 permissions = ('resourcemanager.organizations.get',)
37 urn_component = "organization"
38 urn_id_segments = (-1,) # Just use the last segment of the id in the URN
39 urn_has_project = False
40
41 @staticmethod
42 def get(client, resource_info):
43 org = resource_info['resourceName'].rsplit('/', 1)[-1]
44 return client.execute_query(
45 'get', {'name': "organizations/" + org})
46
47
48@Organization.action_registry.register('set-iam-policy')
49class OrganizationSetIamPolicy(SetIamPolicy):
50 """
51 Overrides the base implementation to process Organization resources correctly.
52 """
53 def _verb_arguments(self, resource):
54 verb_arguments = SetIamPolicy._verb_arguments(self, resource)
55 verb_arguments['body'] = {}
56 return verb_arguments
57
58
59@resources.register('folder')
60class Folder(QueryResourceManager):
61 """GCP resource: https://cloud.google.com/resource-manager/reference/rest/v1/folders
62 """
63 class resource_type(TypeInfo):
64 service = 'cloudresourcemanager'
65 version = 'v2'
66 component = 'folders'
67 scope = 'global'
68 enum_spec = ('list', 'folders', None)
69 name = id = 'name'
70 default_report_fields = [
71 "name", "displayName", "lifecycleState", "createTime", "parent"]
72 asset_type = "cloudresourcemanager.googleapis.com/Folder"
73 perm_service = 'resourcemanager'
74 urn_component = "folder"
75 urn_id_segments = (-1,) # Just use the last segment of the id in the URN
76 urn_has_project = False
77
78 def get_resources(self, resource_ids):
79 client = self.get_client()
80 results = []
81 for rid in resource_ids:
82 if not rid.startswith('folders/'):
83 rid = 'folders/%s' % rid
84 results.append(client.execute_query('get', {'name': rid}))
85 return results
86
87 def get_resource_query(self):
88 if 'query' in self.data:
89 for child in self.data.get('query'):
90 if 'parent' in child:
91 return {'parent': child['parent']}
92
93
94@resources.register('project')
95class Project(QueryResourceManager):
96 """GCP resource: https://cloud.google.com/compute/docs/reference/rest/v1/projects
97 """
98 class resource_type(TypeInfo):
99 service = 'cloudresourcemanager'
100 version = 'v1'
101 component = 'projects'
102 scope = 'global'
103 enum_spec = ('list', 'projects', None)
104 name = id = 'projectId'
105 default_report_fields = [
106 "name", "lifecycleState", "createTime", "parent.id"]
107 asset_type = "cloudresourcemanager.googleapis.com/Project"
108 scc_type = "google.cloud.resourcemanager.Project"
109 perm_service = 'resourcemanager'
110 labels = True
111 labels_op = 'update'
112 urn_component = "project"
113 urn_has_project = False
114
115 @staticmethod
116 def get_label_params(resource, labels):
117 return {'projectId': resource['projectId'],
118 'body': {
119 'name': resource['name'],
120 'parent': resource['parent'],
121 'labels': labels}}
122
123 @staticmethod
124 def get(client, resource_info):
125 return client.execute_query(
126 'get', {'projectId': resource_info['resourceName'].rsplit('/', 1)[-1]})
127
128 def get_resource_query(self):
129 # https://cloud.google.com/resource-manager/reference/rest/v1/projects/list
130 if 'query' in self.data:
131 for child in self.data.get('query'):
132 if 'filter' in child:
133 return {'filter': child['filter']}
134
135
136Project.filter_registry.register('missing', Missing)
137
138
139@Project.filter_registry.register('iam-policy')
140class ProjectIamPolicyFilter(IamPolicyFilter):
141 """
142 Overrides the base implementation to process Project resources correctly.
143 """
144 permissions = ('resourcemanager.projects.getIamPolicy',)
145
146 def _verb_arguments(self, resource):
147 verb_arguments = SetIamPolicy._verb_arguments(self, resource)
148 verb_arguments['body'] = {}
149 return verb_arguments
150
151
152@Project.filter_registry.register('compute-meta')
153class ProjectComputeMetaFilter(ValueFilter):
154 """
155 Allows filtering on project-level compute metadata including common instance metadata
156 and quotas.
157
158 :example:
159
160 Find Projects that have not enabled OS Login for compute instances
161
162 .. code-block:: yaml
163
164 policies:
165 - name: project-compute-os-login-not-enabled
166 resource: gcp.project
167 filters:
168 - type: compute-meta
169 key: "commonInstanceMetadata.items[?key==`enable-oslogin`].value | [0]"
170 op: ne
171 value_type: normalize
172 value: true
173
174 """
175
176 key = 'c7n:projectComputeMeta'
177 permissions = ('compute.projects.get',)
178 schema = type_schema('compute-meta', rinherit=ValueFilter.schema)
179
180 def __call__(self, resource):
181 if self.key in resource:
182 return resource[self.key]
183
184 session = local_session(self.manager.session_factory)
185 self.client = session.client('compute', 'v1', 'projects')
186
187 resource[self.key] = self.client.execute_command('get', {"project": resource['projectId']})
188
189 return super().__call__(resource[self.key])
190
191
192@Project.action_registry.register('delete')
193class ProjectDelete(MethodAction):
194 """Delete a GCP Project
195
196 Note this will also schedule deletion of assets contained within
197 the project. The project will not be accessible, and assets
198 contained within the project may continue to accrue costs within
199 a 30 day period. For details see
200 https://cloud.google.com/resource-manager/docs/creating-managing-projects#shutting_down_projects
201
202 """
203 method_spec = {'op': 'delete'}
204 attr_filter = ('lifecycleState', ('ACTIVE',))
205 schema = type_schema('delete')
206
207 def get_resource_params(self, model, resource):
208 return {'projectId': resource['projectId']}
209
210
211@Project.action_registry.register('set-iam-policy')
212class ProjectSetIamPolicy(SetIamPolicy):
213 """
214 Overrides the base implementation to process Project resources correctly.
215 """
216 def _verb_arguments(self, resource):
217 verb_arguments = SetIamPolicy._verb_arguments(self, resource)
218 verb_arguments['body'] = {}
219 return verb_arguments
220
221
222class HierarchyAction(MethodAction):
223
224 def load_hierarchy(self, resources):
225 parents = {}
226 session = local_session(self.manager.session_factory)
227
228 for r in resources:
229 client = self.get_client(session, self.manager.resource_type)
230 ancestors = client.execute_command(
231 'getAncestry', {'projectId': r['projectId']}).get('ancestor')
232 parents[r['projectId']] = [
233 a['resourceId']['id'] for a in ancestors
234 if a['resourceId']['type'] == 'folder']
235 self.parents = parents
236 self.folder_ids = set(itertools.chain(*self.parents.values()))
237
238 def load_folders(self):
239 folder_manager = self.manager.get_resource_manager('gcp.folder')
240 self.folders = {
241 f['name'].split('/', 1)[-1]: f for f in
242 folder_manager.get_resources(list(self.folder_ids))}
243
244 def load_metadata(self):
245 raise NotImplementedError()
246
247 def diff(self, resources):
248 raise NotImplementedError()
249
250 def process(self, resources):
251 if self.attr_filter:
252 resources = self.filter_resources(resources)
253
254 self.load_hierarchy(resources)
255 self.load_metadata()
256 op_set = self.diff(resources)
257 client = self.manager.get_client()
258 for op in op_set:
259 self.invoke_api(client, *op)
260
261
262@Project.action_registry.register('propagate-labels')
263class ProjectPropagateLabels(HierarchyAction):
264 """Propagate labels from the organization hierarchy to a project.
265
266 folder-labels should resolve to a json data mapping of folder path
267 to labels that should be applied to contained projects.
268
269 as a worked example assume the following resource hierarchy
270
271 ::
272
273 - /dev
274 /network
275 /project-a
276 /ml
277 /project-b
278
279 Given a folder-labels json with contents like
280
281 .. code-block:: json
282
283 {"dev": {"env": "dev", "owner": "dev"},
284 "dev/network": {"owner": "network"},
285 "dev/ml": {"owner": "ml"}
286
287 Running the following policy
288
289 .. code-block:: yaml
290
291 policies:
292 - name: tag-projects
293 resource: gcp.project
294 # use a server side filter to only look at projects
295 # under the /dev folder the id for the dev folder needs
296 # to be manually resolved outside of the policy.
297 query:
298 - filter: "parent.id:389734459211 parent.type:folder"
299 filters:
300 - "tag:owner": absent
301 actions:
302 - type: propagate-labels
303 folder-labels:
304 url: file://folder-labels.json
305
306 Will result in project-a being tagged with owner: network and env: dev
307 and project-b being tagged with owner: ml and env: dev
308
309 """
310 schema = type_schema(
311 'propagate-labels',
312 required=('folder-labels',),
313 **{
314 'folder-labels': {
315 '$ref': '#/definitions/filters_common/value_from'}},
316 )
317
318 attr_filter = ('lifecycleState', ('ACTIVE',))
319 permissions = ('resourcemanager.folders.get',
320 'resourcemanager.projects.update')
321 method_spec = {'op': 'update'}
322
323 def load_metadata(self):
324 """Load hierarchy tags"""
325 self.resolver = ValuesFrom(self.data['folder-labels'], self.manager)
326 self.labels = self.resolver.get_values()
327 self.load_folders()
328 self.resolve_paths()
329
330 def resolve_paths(self):
331 self.folder_paths = {}
332
333 def get_path_segments(fid):
334 p = self.folders[fid]['parent']
335 if p.startswith('folder'):
336 for s in get_path_segments(p.split('/')[-1]):
337 yield s
338 yield self.folders[fid]['displayName']
339
340 for fid in self.folder_ids:
341 self.folder_paths[fid] = '/'.join(get_path_segments(fid))
342
343 def resolve_labels(self, project_id):
344 hlabels = {}
345 parents = self.parents[project_id]
346 for p in reversed(parents):
347 pkeys = [p, self.folder_paths[p], 'folders/%s' % p]
348 for pk in pkeys:
349 hlabels.update(self.labels.get(pk, {}))
350
351 return hlabels
352
353 def diff(self, resources):
354 model = self.manager.resource_type
355
356 for r in resources:
357 hlabels = self.resolve_labels(r['projectId'])
358 if not hlabels:
359 continue
360
361 delta = False
362 rlabels = r.get('labels', {})
363 for k, v in hlabels.items():
364 if k not in rlabels or rlabels[k] != v:
365 delta = True
366 if not delta:
367 continue
368
369 rlabels = dict(rlabels)
370 rlabels.update(hlabels)
371
372 if delta:
373 yield ('update', model.get_label_params(r, rlabels))
374
375
376@Organization.filter_registry.register('essential-contacts')
377class OrgContactsFilter(ListItemFilter):
378 """Filter Resources based on essential contacts configuration
379
380 .. code-block:: yaml
381
382 - name: org-essential-contacts
383 resource: gcp.organization
384 filters:
385 - type: essential-contacts
386 count: 2
387 count_op: gte
388 attrs:
389 - validationState: VALID
390 - type: value
391 key: notificationCategorySubscriptions
392 value: TECHNICAL
393 op: contains
394 """
395 schema = type_schema(
396 'essential-contacts',
397 attrs={'$ref': '#/definitions/filters_common/list_item_attrs'},
398 count={'type': 'number'},
399 count_op={'$ref': '#/definitions/filters_common/comparison_operators'}
400 )
401
402 annotate_items = True
403 permissions = ("essentialcontacts.contacts.list",)
404
405 def get_item_values(self, resource):
406 session = local_session(self.manager.session_factory)
407 client = session.client("essentialcontacts", "v1", "organizations.contacts")
408 pages = client.execute_paged_query('list', {'parent': resource['name'], 'pageSize': 100})
409 contacts = []
410 for page in pages:
411 contacts.extend(page.get('contacts', []))
412 return contacts
413
414
415@Organization.filter_registry.register('org-policy')
416class OrgPoliciesFilter(ListItemFilter):
417 """Filter Resources based on orgpolicy configuration
418
419 .. code-block:: yaml
420
421 - name: org-policy
422 resource: gcp.organization
423 filters:
424 - type: org-policy
425 attrs:
426 - type: value
427 key: constraint
428 value: constraints/iam.allowedPolicyMemberDomains
429 op: contains
430 """
431 schema = type_schema(
432 'org-policy',
433 attrs={'$ref': '#/definitions/filters_common/list_item_attrs'}
434 )
435
436 annotate_items = True
437 permissions = ("orgpolicy.policy.get",)
438
439 def get_item_values(self, resource):
440 session = local_session(self.manager.session_factory)
441 client = session.client("cloudresourcemanager", "v1", "organizations")
442 pages = client.execute_paged_query('listOrgPolicies', {'resource': resource['name']})
443 policies = []
444 for page in pages:
445 policies.extend(page.get('policies', []))
446 return policies
447
448
449@Project.filter_registry.register('access-approval')
450class AccessApprovalFilter(ValueFilter):
451 """Filter Resources based on access approval configuration
452
453 .. code-block:: yaml
454
455 - name: project-access-approval
456 resource: gcp.project
457 filters:
458 - type: access-approval
459 key: enrolledServices.cloudProduct
460 value: "all"
461 """
462 schema = type_schema('access-approval', rinherit=ValueFilter.schema)
463 permissions = ('accessapproval.settings.get',)
464
465 def process(self, resources, event=None):
466 return [r for r in resources
467 if self.match(self.get_access_approval(r))]
468
469 def get_access_approval(self, resource):
470 session = local_session(self.manager.session_factory)
471 client = session.client("accessapproval", "v1", "projects")
472 project = resource['projectId']
473
474 try:
475 access_approval = client.execute_command(
476 'getAccessApprovalSettings',
477 {'name': f"projects/{project}/accessApprovalSettings"},)
478 except HttpError as ex:
479 if (ex.status_code == 400
480 and ex.reason == "Precondition check failed.") \
481 or (ex.status_code == 404):
482 # For above exceptions, it implies that access approval is
483 # not enabled, so we return an empty setting.
484 access_approval = {}
485 else:
486 raise ex
487
488 return access_approval
489
490
491@Organization.filter_registry.register('iam-policy')
492class OrganizationIamPolicyFilter(IamPolicyFilter):
493 """
494 Overrides the base implementation to process Organization resources correctly.
495 """
496 permissions = ('resourcemanager.organizations.getIamPolicy',)
497
498 def _verb_arguments(self, resource):
499 verb_arguments = SetIamPolicy._verb_arguments(self, resource)
500 verb_arguments['body'] = {}
501 return verb_arguments
502
503
504@Folder.filter_registry.register('iam-policy')
505class FolderIamPolicyFilter(IamPolicyFilter):
506 """
507 Overrides the base implementation to process Folder resources correctly.
508 """
509 permissions = ('resourcemanager.folders.getIamPolicy',)
510
511 def _verb_arguments(self, resource):
512 verb_arguments = SetIamPolicy._verb_arguments(self, resource)
513 verb_arguments['body'] = {}
514 return verb_arguments