Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from __future__ import annotations

import json
import os
import shutil
import signal
Expand All @@ -31,7 +32,7 @@
import tempfile
import time
import typing
from typing import Mapping, Sequence
from typing import Any, Mapping, Sequence

# Environment variable that overrides the bundled binary location. Primarily
# for development against a locally-built daemon, and for tests pointing at a
Expand All @@ -41,6 +42,11 @@
# Wheels ship the binary at this path relative to the `_accelerator/` package.
_DEFAULT_BIN_RELATIVE_PATH = "bin/accelerator"

# The daemon writes the principal it resolved to this file in its tempdir
# (alongside the socket) before binding, so the client can verify it matches
# its own locally-resolved identity before routing any RPC.
_IDENTITY_FILENAME = "identity.json"

# How long to wait for the daemon to start listening on its UDS before giving
# up at startup.
_DEFAULT_STARTUP_TIMEOUT = 10.0
Expand Down Expand Up @@ -205,6 +211,36 @@ def close(self) -> None:
self._close_log_file()
self._cleanup_tempdir()

def read_identity(self) -> dict[str, Any]:
"""Read and consume the daemon's ``identity.json``.

The daemon writes the principal it resolved to this file (in the same
0700 tempdir as the socket) before it binds, so it is guaranteed present
once ``start()`` returns. Read it once, then unlink it — the identity is
verified a single time at connect and never needs re-reading.

Raises:
RuntimeError: if the daemon was never started, or if it did not
write an ``identity.json`` (e.g. an older binary), so the caller
can fall back to the native client rather than route blindly.
"""
if self._tempdir is None:
raise RuntimeError("AcceleratorDaemon has not been started")
path = os.path.join(self._tempdir, _IDENTITY_FILENAME)
try:
with open(path, "rb") as f:
return json.load(f)
except FileNotFoundError as exc:
raise RuntimeError(
f"Accelerator daemon did not write {_IDENTITY_FILENAME}; "
"cannot verify its identity"
) from exc
finally:
try:
os.unlink(path)
Comment thread
mutianf marked this conversation as resolved.
except OSError:
pass

def _wait_until_ready(self) -> None:
assert self._proc is not None and self._uds_path is not None
deadline = time.monotonic() + self._startup_timeout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
DeadlineExceeded,
ServiceUnavailable,
)
from google.auth import compute_engine
from google.auth.transport import requests as google_auth_requests
from google.cloud.client import ClientWithProject
from google.cloud.environment_vars import BIGTABLE_EMULATOR # type: ignore
from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper
Expand Down Expand Up @@ -174,6 +176,34 @@
__CROSS_SYNC_OUTPUT__ = "google.cloud.bigtable.data._sync_autogen.client"


# Sentinel distinguishing "principal not yet resolved" from a resolved value of
# None (the unverifiable case), so resolution is attempted at most once.
_UNSET: Any = object()


class _AcceleratorUnverified(Exception):
"""Raised internally when the daemon's identity cannot be verified against
the client's locally resolved principal or effective scopes (either side
unknown), signalling that the caller should fall back to the native client
rather than route."""


def _normalize_scopes(scopes: Any) -> frozenset[str]:
"""Normalize a scope value to a comparable, order-insensitive set.

The daemon writes ``scopes`` to ``identity.json`` as a JSON array of
strings; accept a single comma/space-separated string too, so a contract
drift on either side degrades to a mismatch rather than a crash. Returns a
``frozenset`` so ordering and duplicates never cause a false mismatch (the
daemon does not sort or dedup what it writes).
"""
if not scopes:
return frozenset()
Comment thread
mutianf marked this conversation as resolved.
if isinstance(scopes, str):
scopes = scopes.replace(",", " ").split()
return frozenset(s for s in scopes if s)


@CrossSync.convert_class(
sync_name="BigtableDataClient",
add_mapping_for_name="DataClient",
Expand Down Expand Up @@ -361,6 +391,9 @@ def _init_accelerator_config(
# matches the native client (whose default is TransportType.AUTH_SCOPES),
# rather than the daemon's single hardcoded data scope.
effective_scopes = list(scopes) if scopes else list(TransportType.AUTH_SCOPES)
# Remember the scopes we forwarded so the identity handshake can confirm
# the daemon actually resolved a token for the same effective scope.
self._accelerator_scopes: list[str] = effective_scopes
if effective_scopes:
self._accelerator_flags += ["--scopes", ",".join(effective_scopes)]

Expand All @@ -387,6 +420,41 @@ def _init_accelerator_config(
credentials_file
)

def _resolve_principal(self) -> str | None:
"""Resolve this client's identity to a principal, using only local
signals — never a token-introspection or other external network call.

Returns the service-account email for the credential types that carry
one (SA key, impersonated, workload identity, and Compute Engine after a
metadata-server refresh). Returns ``None`` for identities that expose no
stable local principal (e.g. plain gcloud user ADC), which the caller
treats as unverifiable and routes to the native client.

Cached: resolution runs at most once per client.
"""
cached = getattr(self, "_cached_principal", _UNSET)
Comment thread
mutianf marked this conversation as resolved.
if cached is not _UNSET:
return cast("str | None", cached)

creds = self._credentials
email = getattr(creds, "service_account_email", None)
if (not email or email == "default") and isinstance(
creds, compute_engine.Credentials
):
# Compute Engine credentials only populate the email after a refresh
# against the (link-local, non-egress) metadata server.
try:
creds.refresh(google_auth_requests.Request())
except Exception:
pass
email = getattr(creds, "service_account_email", None)

principal: str | None = email or None
if principal == "default":
principal = None
self._cached_principal = principal
return principal

def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannelType:
"""
This method is called by the gapic transport to create a grpc channel.
Expand Down Expand Up @@ -1307,7 +1375,21 @@ def _start_accelerator(self) -> None:
)
try:
server.start()
# Backstop against an identity flip: even under config we forwarded,
# the daemon's independent ADC resolution could land on a different
# principal. Verify before routing any RPC through it.
self._verify_daemon_identity(server)
self._accelerator_client = AcceleratorClientType(server.uds_path)
except _AcceleratorUnverified:
server.close()
warnings.warn(
"Accelerator disabled: could not verify that the daemon "
"resolves the same identity as this client; using the native "
"client instead.",
RuntimeWarning,
stacklevel=2,
)
return
except BaseException:
server.close()
raise
Expand All @@ -1325,6 +1407,43 @@ def _use_accelerator(self, method_name: str) -> bool:
and not self._accelerator_breaker.bypass()
)

def _verify_daemon_identity(self, server: AcceleratorDaemon) -> None:
"""Confirm the daemon resolved the same identity this client did.

Compares the principal *and* the effective auth scopes the daemon wrote
to ``identity.json`` against the client's own — the daemon mints tokens
for its effective scope, so a scope drift is as much an identity flip as
a principal drift. If either side of either check is unknown, raise
``_AcceleratorUnverified`` (caller falls back to native). If both are
known but differ, raise ``RuntimeError`` and refuse to route — this is
the identity flip we are guarding against.
"""
identity = server.read_identity()

daemon_principal = identity.get("principal") or None
own_principal = self.client._resolve_principal()
Comment thread
mutianf marked this conversation as resolved.
if own_principal is None or daemon_principal is None:
raise _AcceleratorUnverified()
if own_principal != daemon_principal:
raise RuntimeError(
"Accelerator identity mismatch: this client resolved "
f"{own_principal!r} but the daemon resolved "
f"{daemon_principal!r}; refusing to route RPCs through the "
"accelerator."
)

daemon_scopes = _normalize_scopes(identity.get("scopes"))
own_scopes = _normalize_scopes(self.client._accelerator_scopes)
if not own_scopes or not daemon_scopes:
raise _AcceleratorUnverified()
if own_scopes != daemon_scopes:
raise RuntimeError(
"Accelerator scope mismatch: this client forwarded "
f"{sorted(own_scopes)!r} but the daemon resolved "
f"{sorted(daemon_scopes)!r}; refusing to route RPCs through the "
"accelerator."
)

@property
@abc.abstractmethod
def _request_path(self) -> dict[str, str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
DeadlineExceeded,
ServiceUnavailable,
)
from google.auth import compute_engine
from google.auth.transport import requests as google_auth_requests
from google.cloud.client import ClientWithProject
from google.cloud.environment_vars import BIGTABLE_EMULATOR
from google.protobuf.internal.enum_type_wrapper import EnumTypeWrapper
Expand Down Expand Up @@ -127,6 +129,29 @@
from google.cloud.bigtable.data.execute_query._sync_autogen.execute_query_iterator import (
ExecuteQueryIterator,
)
_UNSET: Any = object()


class _AcceleratorUnverified(Exception):
"""Raised internally when the daemon's identity cannot be verified against
the client's locally resolved principal or effective scopes (either side
unknown), signalling that the caller should fall back to the native client
rather than route."""


def _normalize_scopes(scopes: Any) -> frozenset[str]:
"""Normalize a scope value to a comparable, order-insensitive set.

The daemon writes ``scopes`` to ``identity.json`` as a JSON array of
strings; accept a single comma/space-separated string too, so a contract
drift on either side degrades to a mismatch rather than a crash. Returns a
``frozenset`` so ordering and duplicates never cause a false mismatch (the
daemon does not sort or dedup what it writes)."""
if not scopes:
return frozenset()
if isinstance(scopes, str):
scopes = scopes.replace(",", " ").split()
return frozenset((s for s in scopes if s))


@CrossSync._Sync_Impl.add_mapping_decorator("DataClient")
Expand Down Expand Up @@ -269,6 +294,7 @@ def _init_accelerator_config(
self._accelerator_blocked_reason = "client_cert_source (mTLS) cannot be forwarded to the accelerator daemon"
scopes = getattr(client_options, "scopes", None) if client_options else None
effective_scopes = list(scopes) if scopes else list(TransportType.AUTH_SCOPES)
self._accelerator_scopes: list[str] = effective_scopes
if effective_scopes:
self._accelerator_flags += ["--scopes", ",".join(effective_scopes)]
if client_options is not None:
Expand All @@ -288,6 +314,36 @@ def _init_accelerator_config(
credentials_file
)

def _resolve_principal(self) -> str | None:
"""Resolve this client's identity to a principal, using only local
signals — never a token-introspection or other external network call.

Returns the service-account email for the credential types that carry
one (SA key, impersonated, workload identity, and Compute Engine after a
metadata-server refresh). Returns ``None`` for identities that expose no
stable local principal (e.g. plain gcloud user ADC), which the caller
treats as unverifiable and routes to the native client.

Cached: resolution runs at most once per client."""
cached = getattr(self, "_cached_principal", _UNSET)
if cached is not _UNSET:
return cast("str | None", cached)
creds = self._credentials
email = getattr(creds, "service_account_email", None)
if (not email or email == "default") and isinstance(
creds, compute_engine.Credentials
):
try:
creds.refresh(google_auth_requests.Request())
except Exception:
pass
email = getattr(creds, "service_account_email", None)
principal: str | None = email or None
if principal == "default":
principal = None
self._cached_principal = principal
return principal

def _build_grpc_channel(self, *args, **kwargs) -> SwappableChannelType:
"""This method is called by the gapic transport to create a grpc channel.

Expand Down Expand Up @@ -1019,7 +1075,16 @@ def _start_accelerator(self) -> None:
)
try:
server.start()
self._verify_daemon_identity(server)
self._accelerator_client = AcceleratorClientType(server.uds_path)
except _AcceleratorUnverified:
server.close()
warnings.warn(
"Accelerator disabled: could not verify that the daemon resolves the same identity as this client; using the native client instead.",
RuntimeWarning,
stacklevel=2,
)
return
except BaseException:
server.close()
raise
Expand All @@ -1036,6 +1101,34 @@ def _use_accelerator(self, method_name: str) -> bool:
and (not self._accelerator_breaker.bypass())
)

def _verify_daemon_identity(self, server: AcceleratorDaemon) -> None:
"""Confirm the daemon resolved the same identity this client did.

Compares the principal *and* the effective auth scopes the daemon wrote
to ``identity.json`` against the client's own — the daemon mints tokens
for its effective scope, so a scope drift is as much an identity flip as
a principal drift. If either side of either check is unknown, raise
``_AcceleratorUnverified`` (caller falls back to native). If both are
known but differ, raise ``RuntimeError`` and refuse to route — this is
the identity flip we are guarding against."""
identity = server.read_identity()
daemon_principal = identity.get("principal") or None
own_principal = self.client._resolve_principal()
if own_principal is None or daemon_principal is None:
raise _AcceleratorUnverified()
if own_principal != daemon_principal:
raise RuntimeError(
f"Accelerator identity mismatch: this client resolved {own_principal!r} but the daemon resolved {daemon_principal!r}; refusing to route RPCs through the accelerator."
)
daemon_scopes = _normalize_scopes(identity.get("scopes"))
own_scopes = _normalize_scopes(self.client._accelerator_scopes)
if not own_scopes or not daemon_scopes:
raise _AcceleratorUnverified()
if own_scopes != daemon_scopes:
raise RuntimeError(
f"Accelerator scope mismatch: this client forwarded {sorted(own_scopes)!r} but the daemon resolved {sorted(daemon_scopes)!r}; refusing to route RPCs through the accelerator."
)

@property
@abc.abstractmethod
def _request_path(self) -> dict[str, str]:
Expand Down
Loading
Loading