Skip to content

Commit c7105d3

Browse files
authored
feat: implement updated design for regional access boundary (#16084)
Original PR: googleapis/google-auth-library-python#1955 Make the fetching async and non blocking. Implement proactive refresh every 6 hours. Centralize the logic in a new class. Remove no-op signal and checks. Refactor to Regional Access Boundary name.
1 parent d120f4c commit c7105d3

23 files changed

Lines changed: 1363 additions & 1745 deletions

packages/google-auth/google/auth/_constants.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

packages/google-auth/google/auth/_helpers.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
import hashlib
2222
import json
2323
import logging
24-
import os
2524
import sys
2625
from typing import Any, Dict, Mapping, Optional, Union
2726
import urllib
@@ -308,46 +307,6 @@ def unpadded_urlsafe_b64encode(value):
308307
return base64.urlsafe_b64encode(value).rstrip(b"=")
309308

310309

311-
def get_bool_from_env(variable_name, default=False):
312-
"""Gets a boolean value from an environment variable.
313-
314-
The environment variable is interpreted as a boolean with the following
315-
(case-insensitive) rules:
316-
- "true", "1" are considered true.
317-
- "false", "0" are considered false.
318-
Any other values will raise an exception.
319-
320-
Args:
321-
variable_name (str): The name of the environment variable.
322-
default (bool): The default value if the environment variable is not
323-
set.
324-
325-
Returns:
326-
bool: The boolean value of the environment variable.
327-
328-
Raises:
329-
google.auth.exceptions.InvalidValue: If the environment variable is
330-
set to a value that can not be interpreted as a boolean.
331-
"""
332-
value = os.environ.get(variable_name)
333-
334-
if value is None:
335-
return default
336-
337-
value = value.lower()
338-
339-
if value in ("true", "1"):
340-
return True
341-
elif value in ("false", "0"):
342-
return False
343-
else:
344-
raise exceptions.InvalidValue(
345-
'Environment variable "{}" must be one of "true", "false", "1", or "0".'.format(
346-
variable_name
347-
)
348-
)
349-
350-
351310
def is_python_3():
352311
"""Check if the Python interpreter is Python 2 or 3.
353312
Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
# Copyright 2026 Google Inc.
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+
"""Utilities for Regional Access Boundary management."""
16+
17+
import copy
18+
import datetime
19+
import functools
20+
import logging
21+
import os
22+
import threading
23+
from typing import NamedTuple, Optional
24+
25+
from google.auth import _helpers
26+
from google.auth import environment_vars
27+
28+
_LOGGER = logging.getLogger(__name__)
29+
30+
31+
@functools.lru_cache()
32+
def is_regional_access_boundary_enabled():
33+
"""Checks if Regional Access Boundary is enabled via environment variable.
34+
35+
The environment variable is interpreted as a boolean with the following
36+
(case-insensitive) rules:
37+
- "true", "1" are considered true.
38+
- Any other value (or unset) is considered false.
39+
40+
Returns:
41+
bool: True if Regional Access Boundary is enabled, False otherwise.
42+
"""
43+
value = os.environ.get(environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED)
44+
if value is None:
45+
return False
46+
47+
return value.lower() in ("true", "1")
48+
49+
50+
# The default lifetime for a cached Regional Access Boundary.
51+
DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL = datetime.timedelta(hours=6)
52+
53+
# The period of time prior to the boundary's expiration when a background refresh
54+
# is proactively triggered.
55+
REGIONAL_ACCESS_BOUNDARY_REFRESH_THRESHOLD = datetime.timedelta(hours=1)
56+
57+
# The initial cooldown period for a failed Regional Access Boundary lookup.
58+
DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN = datetime.timedelta(minutes=15)
59+
60+
# The maximum cooldown period for a failed Regional Access Boundary lookup.
61+
MAX_REGIONAL_ACCESS_BOUNDARY_COOLDOWN = datetime.timedelta(hours=6)
62+
63+
64+
# The header key used for Regional Access Boundaries.
65+
_REGIONAL_ACCESS_BOUNDARY_HEADER = "x-allowed-locations"
66+
67+
68+
class _RegionalAccessBoundaryData(NamedTuple):
69+
"""Data container for a Regional Access Boundary snapshot.
70+
71+
Attributes:
72+
encoded_locations (Optional[str]): The encoded Regional Access Boundary string.
73+
expiry (Optional[datetime.datetime]): The hard expiration time of the boundary data.
74+
cooldown_expiry (Optional[datetime.datetime]): The time until which further lookups are skipped.
75+
cooldown_duration (datetime.timedelta): The current duration for the exponential cooldown.
76+
"""
77+
78+
encoded_locations: Optional[str]
79+
expiry: Optional[datetime.datetime]
80+
cooldown_expiry: Optional[datetime.datetime]
81+
cooldown_duration: datetime.timedelta
82+
83+
84+
class _RegionalAccessBoundaryManager(object):
85+
"""Manages the Regional Access Boundary state and its background refresh.
86+
87+
The actual data is held in an immutable `_RegionalAccessBoundaryData` object
88+
and is swapped atomically to ensure thread-safe, lock-free reads.
89+
"""
90+
91+
def __init__(self):
92+
self._data = _RegionalAccessBoundaryData(
93+
encoded_locations=None,
94+
expiry=None,
95+
cooldown_expiry=None,
96+
cooldown_duration=DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN,
97+
)
98+
self.refresh_manager = _RegionalAccessBoundaryRefreshManager()
99+
self._update_lock = threading.Lock()
100+
101+
def __getstate__(self):
102+
"""Pickle helper that serializes the _update_lock attribute."""
103+
state = self.__dict__.copy()
104+
state["_update_lock"] = None
105+
return state
106+
107+
def __setstate__(self, state):
108+
"""Pickle helper that deserializes the _update_lock attribute."""
109+
self.__dict__.update(state)
110+
self._update_lock = threading.Lock()
111+
112+
def apply_headers(self, headers):
113+
"""Applies the Regional Access Boundary header to the provided dictionary.
114+
115+
If the boundary is valid, the 'x-allowed-locations' header is added
116+
or updated. Otherwise, the header is removed to ensure no stale
117+
data is sent.
118+
119+
Args:
120+
headers (MutableMapping[str, str]): The headers dictionary to update.
121+
"""
122+
rab_data = self._data
123+
124+
if rab_data.encoded_locations and (
125+
rab_data.expiry is not None and _helpers.utcnow() < rab_data.expiry
126+
):
127+
headers[_REGIONAL_ACCESS_BOUNDARY_HEADER] = rab_data.encoded_locations
128+
else:
129+
headers.pop(_REGIONAL_ACCESS_BOUNDARY_HEADER, None)
130+
131+
def maybe_start_refresh(self, credentials, request):
132+
"""Starts a background thread to refresh the Regional Access Boundary if needed.
133+
134+
Args:
135+
credentials (google.auth.credentials.Credentials): The credentials to refresh.
136+
request (google.auth.transport.Request): The object used to make HTTP requests.
137+
"""
138+
rab_data = self._data
139+
140+
# Don't start a new refresh if the Regional Access Boundary info is still fresh.
141+
if (
142+
rab_data.encoded_locations
143+
and rab_data.expiry
144+
and _helpers.utcnow()
145+
< (rab_data.expiry - REGIONAL_ACCESS_BOUNDARY_REFRESH_THRESHOLD)
146+
):
147+
return
148+
149+
# Don't start a new refresh if the cooldown is still in effect.
150+
if rab_data.cooldown_expiry and _helpers.utcnow() < rab_data.cooldown_expiry:
151+
return
152+
153+
# If all checks pass, start the background refresh.
154+
self.refresh_manager.start_refresh(credentials, request, self)
155+
156+
157+
class _RegionalAccessBoundaryRefreshThread(threading.Thread):
158+
"""Thread for background refreshing of the Regional Access Boundary."""
159+
160+
def __init__(self, credentials, request, rab_manager):
161+
super().__init__()
162+
self.daemon = True
163+
self._credentials = credentials
164+
self._request = request
165+
self._rab_manager = rab_manager
166+
167+
def run(self):
168+
"""
169+
Performs the Regional Access Boundary lookup and updates the state.
170+
171+
This method is run in a separate thread. It delegates the actual lookup
172+
to the credentials object's `_lookup_regional_access_boundary` method.
173+
Based on the lookup's outcome (success or complete failure after retries),
174+
it updates the cached Regional Access Boundary information,
175+
its expiry, its cooldown expiry, and its exponential cooldown duration.
176+
"""
177+
# Catch exceptions (e.g., from the underlying transport) to prevent the
178+
# background thread from crashing. This ensures we can gracefully enter
179+
# an exponential cooldown state on failure.
180+
try:
181+
regional_access_boundary_info = (
182+
self._credentials._lookup_regional_access_boundary(self._request)
183+
)
184+
except Exception as e:
185+
if _helpers.is_logging_enabled(_LOGGER):
186+
_LOGGER.warning(
187+
"Asynchronous Regional Access Boundary lookup raised an exception: %s",
188+
e,
189+
exc_info=True,
190+
)
191+
regional_access_boundary_info = None
192+
193+
with self._rab_manager._update_lock:
194+
# Capture the current state before calculating updates.
195+
current_data = self._rab_manager._data
196+
197+
if regional_access_boundary_info:
198+
# On success, update the boundary and its expiry, and clear any cooldown.
199+
encoded_locations = regional_access_boundary_info.get(
200+
"encodedLocations"
201+
)
202+
updated_data = _RegionalAccessBoundaryData(
203+
encoded_locations=encoded_locations,
204+
expiry=_helpers.utcnow() + DEFAULT_REGIONAL_ACCESS_BOUNDARY_TTL,
205+
cooldown_expiry=None,
206+
cooldown_duration=DEFAULT_REGIONAL_ACCESS_BOUNDARY_COOLDOWN,
207+
)
208+
if _helpers.is_logging_enabled(_LOGGER):
209+
_LOGGER.debug(
210+
"Asynchronous Regional Access Boundary lookup successful."
211+
)
212+
else:
213+
# On failure, calculate cooldown and update state.
214+
if _helpers.is_logging_enabled(_LOGGER):
215+
_LOGGER.warning(
216+
"Asynchronous Regional Access Boundary lookup failed. Entering cooldown."
217+
)
218+
219+
next_cooldown_expiry = (
220+
_helpers.utcnow() + current_data.cooldown_duration
221+
)
222+
next_cooldown_duration = min(
223+
current_data.cooldown_duration * 2,
224+
MAX_REGIONAL_ACCESS_BOUNDARY_COOLDOWN,
225+
)
226+
227+
# If the refresh failed, we keep reusing the existing data unless
228+
# it has reached its hard expiration time.
229+
if current_data.expiry and _helpers.utcnow() > current_data.expiry:
230+
next_encoded_locations = None
231+
next_expiry = None
232+
else:
233+
next_encoded_locations = current_data.encoded_locations
234+
next_expiry = current_data.expiry
235+
236+
updated_data = _RegionalAccessBoundaryData(
237+
encoded_locations=next_encoded_locations,
238+
expiry=next_expiry,
239+
cooldown_expiry=next_cooldown_expiry,
240+
cooldown_duration=next_cooldown_duration,
241+
)
242+
243+
# Perform the atomic swap of the state object.
244+
self._rab_manager._data = updated_data
245+
246+
247+
class _RegionalAccessBoundaryRefreshManager(object):
248+
"""Manages a thread for background refreshing of the Regional Access Boundary."""
249+
250+
def __init__(self):
251+
self._lock = threading.Lock()
252+
self._worker = None
253+
254+
def __getstate__(self):
255+
"""Pickle helper that serializes the _lock and _worker attributes."""
256+
state = self.__dict__.copy()
257+
state["_lock"] = None
258+
state["_worker"] = None
259+
return state
260+
261+
def __setstate__(self, state):
262+
"""Pickle helper that deserializes the _lock and _worker attributes."""
263+
self.__dict__.update(state)
264+
self._lock = threading.Lock()
265+
self._worker = None
266+
267+
def start_refresh(self, credentials, request, rab_manager):
268+
"""
269+
Starts a background thread to refresh the Regional Access Boundary if one is not already running.
270+
271+
Args:
272+
credentials (CredentialsWithRegionalAccessBoundary): The credentials
273+
to refresh.
274+
request (google.auth.transport.Request): The object used to make
275+
HTTP requests.
276+
rab_manager (_RegionalAccessBoundaryManager): The manager container to update.
277+
"""
278+
with self._lock:
279+
if self._worker and self._worker.is_alive():
280+
# A refresh is already in progress.
281+
return
282+
283+
try:
284+
copied_request = copy.deepcopy(request)
285+
except Exception as e:
286+
if _helpers.is_logging_enabled(_LOGGER):
287+
_LOGGER.warning(
288+
"Could not deepcopy transport for background RAB refresh. "
289+
"Skipping background refresh to avoid thread safety issues. "
290+
"Exception: %s",
291+
e,
292+
)
293+
return
294+
295+
self._worker = _RegionalAccessBoundaryRefreshThread(
296+
credentials, copied_request, rab_manager
297+
)
298+
self._worker.start()

0 commit comments

Comments
 (0)