Skip to content

Commit a29073c

Browse files
authored
feat: add support for bucket IP filter (#1516)
* feat: add support for bucket IP filter * minor fix * fix unit tests * change create bucket with filter system test * add more system tests * update system tests * resolving comments
1 parent 6a9923e commit a29073c

File tree

5 files changed

+420
-0
lines changed

5 files changed

+420
-0
lines changed

google/cloud/storage/bucket.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
from google.cloud.storage.constants import REGIONAL_LEGACY_STORAGE_CLASS
5757
from google.cloud.storage.constants import REGION_LOCATION_TYPE
5858
from google.cloud.storage.constants import STANDARD_STORAGE_CLASS
59+
from google.cloud.storage.ip_filter import IPFilter
5960
from google.cloud.storage.notification import BucketNotification
6061
from google.cloud.storage.notification import NONE_PAYLOAD_FORMAT
6162
from google.cloud.storage.retry import DEFAULT_RETRY
@@ -88,6 +89,7 @@
8889
_FROM_STRING_MESSAGE = (
8990
"Bucket.from_string() is deprecated. " "Use Bucket.from_uri() instead."
9091
)
92+
_IP_FILTER_PROPERTY = "ipFilter"
9193

9294

9395
def _blobs_page_start(iterator, page, response):
@@ -3887,6 +3889,59 @@ def generate_signed_url(
38873889
query_parameters=query_parameters,
38883890
)
38893891

3892+
@property
3893+
def ip_filter(self):
3894+
"""Retrieve or set the IP Filter configuration for this bucket.
3895+
3896+
See https://cloud.google.com/storage/docs/ip-filtering-overview and
3897+
https://cloud.google.com/storage/docs/json_api/v1/buckets#ipFilter
3898+
3899+
.. note::
3900+
The getter for this property returns an
3901+
:class:`~google.cloud.storage.ip_filter.IPFilter` object, which is a
3902+
structured representation of the bucket's IP filter configuration.
3903+
Modifying the returned object has no effect. To update the bucket's
3904+
IP filter, create and assign a new ``IPFilter`` object to this
3905+
property and then call
3906+
:meth:`~google.cloud.storage.bucket.Bucket.patch`.
3907+
3908+
.. code-block:: python
3909+
3910+
from google.cloud.storage.ip_filter import (
3911+
IPFilter,
3912+
PublicNetworkSource,
3913+
)
3914+
3915+
ip_filter = IPFilter()
3916+
ip_filter.mode = "Enabled"
3917+
ip_filter.public_network_source = PublicNetworkSource(
3918+
allowed_ip_cidr_ranges=["203.0.113.5/32"]
3919+
)
3920+
bucket.ip_filter = ip_filter
3921+
bucket.patch()
3922+
3923+
:setter: Set the IP Filter configuration for this bucket.
3924+
:getter: Gets the IP Filter configuration for this bucket.
3925+
3926+
:rtype: :class:`~google.cloud.storage.ip_filter.IPFilter` or ``NoneType``
3927+
:returns:
3928+
An ``IPFilter`` object representing the configuration, or ``None``
3929+
if no filter is configured.
3930+
"""
3931+
resource = self._properties.get(_IP_FILTER_PROPERTY)
3932+
if resource:
3933+
return IPFilter._from_api_resource(resource)
3934+
return None
3935+
3936+
@ip_filter.setter
3937+
def ip_filter(self, value):
3938+
if value is None:
3939+
self._patch_property(_IP_FILTER_PROPERTY, None)
3940+
elif isinstance(value, IPFilter):
3941+
self._patch_property(_IP_FILTER_PROPERTY, value._to_api_resource())
3942+
else:
3943+
self._patch_property(_IP_FILTER_PROPERTY, value)
3944+
38903945

38913946
class SoftDeletePolicy(dict):
38923947
"""Map a bucket's soft delete policy.

google/cloud/storage/ip_filter.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright 2014 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""IP Filter configuration for Google Cloud Storage Buckets."""
16+
17+
from typing import Dict, Any, Optional, List
18+
19+
_MODE = "mode"
20+
_PUBLIC_NETWORK_SOURCE = "publicNetworkSource"
21+
_VPC_NETWORK_SOURCES = "vpcNetworkSources"
22+
_ALLOWED_IP_CIDR_RANGES = "allowedIpCidrRanges"
23+
_NETWORK = "network"
24+
_ALLOW_ALL_SERVICE_AGENT_ACCESS = "allowAllServiceAgentAccess"
25+
_ALLOW_CROSS_ORG_VPCS = "allowCrossOrgVpcs"
26+
27+
28+
class PublicNetworkSource:
29+
"""Represents a public network source for a GCS Bucket IP Filter.
30+
31+
:type allowed_ip_cidr_ranges: list(str) or None
32+
:param allowed_ip_cidr_ranges: A list of public IPv4 or IPv6 ranges in
33+
CIDR notation that are allowed to access
34+
the bucket.
35+
"""
36+
37+
def __init__(self, allowed_ip_cidr_ranges: Optional[List[str]] = None):
38+
self.allowed_ip_cidr_ranges = allowed_ip_cidr_ranges or []
39+
40+
def _to_api_resource(self) -> Dict[str, Any]:
41+
"""Serializes this object to a dictionary for API requests."""
42+
return {_ALLOWED_IP_CIDR_RANGES: self.allowed_ip_cidr_ranges}
43+
44+
45+
class VpcNetworkSource:
46+
"""Represents a VPC network source for a GCS Bucket IP Filter.
47+
48+
:type network: str
49+
:param network: The resource name of the VPC network.
50+
51+
:type allowed_ip_cidr_ranges: list(str) or None
52+
:param allowed_ip_cidr_ranges: A list of IPv4 or IPv6 ranges in CIDR
53+
notation allowed to access the bucket
54+
from this VPC.
55+
"""
56+
57+
def __init__(
58+
self, network: str, allowed_ip_cidr_ranges: Optional[List[str]] = None
59+
):
60+
self.network = network
61+
self.allowed_ip_cidr_ranges = allowed_ip_cidr_ranges or []
62+
63+
def _to_api_resource(self) -> Dict[str, Any]:
64+
"""Serializes this object to a dictionary for API requests."""
65+
return {
66+
_NETWORK: self.network,
67+
_ALLOWED_IP_CIDR_RANGES: self.allowed_ip_cidr_ranges,
68+
}
69+
70+
71+
class IPFilter:
72+
"""Represents a GCS Bucket IP Filter configuration.
73+
74+
This class is a helper for constructing the IP Filter dictionary to be
75+
assigned to a bucket's ``ip_filter`` property.
76+
"""
77+
78+
"""
79+
Attributes:
80+
mode (str): Required. The mode of the IP filter. Can be "Enabled" or "Disabled".
81+
allow_all_service_agent_access (bool): Required. If True, allows Google
82+
Cloud service agents to bypass the IP filter.
83+
public_network_source (PublicNetworkSource): (Optional) The configuration
84+
for requests from the public internet.
85+
vpc_network_sources (list(VpcNetworkSource)): (Optional) A list of
86+
configurations for requests from VPC networks.
87+
allow_cross_org_vpcs (bool): (Optional) If True, allows VPCs from
88+
other organizations to be used in the configuration.
89+
"""
90+
91+
def __init__(self):
92+
self.mode: Optional[str] = None
93+
self.public_network_source: Optional[PublicNetworkSource] = None
94+
self.vpc_network_sources: List[VpcNetworkSource] = []
95+
self.allow_all_service_agent_access: Optional[bool] = None
96+
self.allow_cross_org_vpcs: Optional[bool] = None
97+
98+
@classmethod
99+
def _from_api_resource(cls, resource: Dict[str, Any]) -> "IPFilter":
100+
"""Factory: creates an IPFilter instance from a server response."""
101+
ip_filter = cls()
102+
ip_filter.mode = resource.get(_MODE)
103+
ip_filter.allow_all_service_agent_access = resource.get(
104+
_ALLOW_ALL_SERVICE_AGENT_ACCESS, None
105+
)
106+
107+
public_network_source_data = resource.get(_PUBLIC_NETWORK_SOURCE, None)
108+
if public_network_source_data:
109+
ip_filter.public_network_source = PublicNetworkSource(
110+
allowed_ip_cidr_ranges=public_network_source_data.get(
111+
_ALLOWED_IP_CIDR_RANGES, []
112+
)
113+
)
114+
115+
vns_res_list = resource.get(_VPC_NETWORK_SOURCES, [])
116+
ip_filter.vpc_network_sources = [
117+
VpcNetworkSource(
118+
network=vns.get(_NETWORK),
119+
allowed_ip_cidr_ranges=vns.get(_ALLOWED_IP_CIDR_RANGES, []),
120+
)
121+
for vns in vns_res_list
122+
]
123+
ip_filter.allow_cross_org_vpcs = resource.get(_ALLOW_CROSS_ORG_VPCS, None)
124+
return ip_filter
125+
126+
def _to_api_resource(self) -> Dict[str, Any]:
127+
"""Serializes this object to a dictionary for API requests."""
128+
resource = {
129+
_MODE: self.mode,
130+
_ALLOW_ALL_SERVICE_AGENT_ACCESS: self.allow_all_service_agent_access,
131+
}
132+
133+
if self.public_network_source:
134+
resource[
135+
_PUBLIC_NETWORK_SOURCE
136+
] = self.public_network_source._to_api_resource()
137+
if self.vpc_network_sources is not None:
138+
resource[_VPC_NETWORK_SOURCES] = [
139+
vns._to_api_resource() for vns in self.vpc_network_sources
140+
]
141+
if self.allow_cross_org_vpcs is not None:
142+
resource[_ALLOW_CROSS_ORG_VPCS] = self.allow_cross_org_vpcs
143+
return resource

tests/system/test_bucket.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717

1818
from google.api_core import exceptions
1919
from . import _helpers
20+
from google.cloud.storage.ip_filter import (
21+
IPFilter,
22+
PublicNetworkSource,
23+
VpcNetworkSource,
24+
)
2025

2126

2227
def test_bucket_create_w_alt_storage_class(storage_client, buckets_to_delete):
@@ -1299,3 +1304,66 @@ def test_new_bucket_with_hierarchical_namespace(
12991304
bucket = storage_client.create_bucket(bucket_obj)
13001305
buckets_to_delete.append(bucket)
13011306
assert bucket.hierarchical_namespace_enabled is True
1307+
1308+
1309+
def test_bucket_ip_filter_patch(storage_client, buckets_to_delete):
1310+
"""Test setting and clearing IP filter configuration without enabling enforcement."""
1311+
bucket_name = _helpers.unique_name("ip-filter-control")
1312+
bucket = _helpers.retry_429_503(storage_client.create_bucket)(bucket_name)
1313+
buckets_to_delete.append(bucket)
1314+
1315+
ip_filter = IPFilter()
1316+
ip_filter.mode = "Disabled"
1317+
ip_filter.allow_all_service_agent_access = True
1318+
ip_filter.public_network_source = PublicNetworkSource(
1319+
allowed_ip_cidr_ranges=["203.0.113.10/32"]
1320+
)
1321+
ip_filter.vpc_network_sources.append(
1322+
VpcNetworkSource(
1323+
network=f"projects/{storage_client.project}/global/networks/default",
1324+
allowed_ip_cidr_ranges=["10.0.0.0/8"],
1325+
)
1326+
)
1327+
bucket.ip_filter = ip_filter
1328+
bucket.patch()
1329+
1330+
# Reload and verify the full configuration was set correctly.
1331+
bucket.reload()
1332+
reloaded_filter = bucket.ip_filter
1333+
assert reloaded_filter is not None
1334+
assert reloaded_filter.mode == "Disabled"
1335+
assert reloaded_filter.allow_all_service_agent_access is True
1336+
assert reloaded_filter.public_network_source.allowed_ip_cidr_ranges == [
1337+
"203.0.113.10/32"
1338+
]
1339+
assert len(reloaded_filter.vpc_network_sources) == 1
1340+
1341+
def test_list_buckets_with_ip_filter(storage_client, buckets_to_delete):
1342+
"""Test that listing buckets returns a summarized IP filter."""
1343+
bucket_name = _helpers.unique_name("ip-filter-list")
1344+
bucket = _helpers.retry_429_503(storage_client.create_bucket)(bucket_name)
1345+
buckets_to_delete.append(bucket)
1346+
1347+
ip_filter = IPFilter()
1348+
ip_filter.mode = "Disabled"
1349+
ip_filter.allow_all_service_agent_access = True
1350+
ip_filter.public_network_source = PublicNetworkSource(
1351+
allowed_ip_cidr_ranges=["203.0.113.10/32"]
1352+
)
1353+
bucket.ip_filter = ip_filter
1354+
bucket.patch()
1355+
1356+
buckets_list = list(storage_client.list_buckets(prefix=bucket_name))
1357+
found_bucket = next((b for b in buckets_list if b.name == bucket_name), None)
1358+
1359+
assert found_bucket is not None
1360+
summarized_filter = found_bucket.ip_filter
1361+
1362+
assert summarized_filter is not None
1363+
assert summarized_filter.mode == "Disabled"
1364+
assert summarized_filter.allow_all_service_agent_access is True
1365+
1366+
# Check that the summarized filter does not include full details.
1367+
assert summarized_filter.public_network_source is None
1368+
assert summarized_filter.vpc_network_sources == []
1369+

tests/unit/test_bucket.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4612,6 +4612,54 @@ def test_generate_signed_url_v4_w_incompatible_params(self):
46124612
virtual_hosted_style=True, bucket_bound_hostname="cdn.example.com"
46134613
)
46144614

4615+
def test_ip_filter_getter_unset(self):
4616+
"""Test that ip_filter is None when not set."""
4617+
bucket = self._make_one()
4618+
self.assertIsNone(bucket.ip_filter)
4619+
4620+
def test_ip_filter_getter_w_value(self):
4621+
"""Test getting an existing ip_filter configuration."""
4622+
from google.cloud.storage.ip_filter import IPFilter
4623+
4624+
ipf_property = {"mode": "Enabled"}
4625+
properties = {"ipFilter": ipf_property}
4626+
bucket = self._make_one(properties=properties)
4627+
4628+
ip_filter = bucket.ip_filter
4629+
self.assertIsInstance(ip_filter, IPFilter)
4630+
self.assertEqual(ip_filter.mode, "Enabled")
4631+
4632+
def test_ip_filter_setter(self):
4633+
"""Test setting the ip_filter with a helper class."""
4634+
from google.cloud.storage.ip_filter import IPFilter
4635+
from google.cloud.storage.bucket import _IP_FILTER_PROPERTY
4636+
4637+
bucket = self._make_one()
4638+
ip_filter = IPFilter()
4639+
ip_filter.mode = "Enabled"
4640+
4641+
bucket.ip_filter = ip_filter
4642+
4643+
self.assertIn(_IP_FILTER_PROPERTY, bucket._changes)
4644+
self.assertEqual(
4645+
bucket._properties[_IP_FILTER_PROPERTY],
4646+
{
4647+
"mode": "Enabled",
4648+
"vpcNetworkSources": [],
4649+
"allowAllServiceAgentAccess": None,
4650+
},
4651+
)
4652+
4653+
def test_ip_filter_setter_w_none(self):
4654+
"""Test clearing the ip_filter by setting it to None."""
4655+
from google.cloud.storage.bucket import _IP_FILTER_PROPERTY
4656+
4657+
bucket = self._make_one(properties={"ipFilter": {"mode": "Enabled"}})
4658+
bucket.ip_filter = None
4659+
4660+
self.assertIn(_IP_FILTER_PROPERTY, bucket._changes)
4661+
self.assertIsNone(bucket._properties.get(_IP_FILTER_PROPERTY))
4662+
46154663

46164664
class Test__item_to_notification(unittest.TestCase):
46174665
def _call_fut(self, iterator, item):

0 commit comments

Comments
 (0)