|
3 | 3 | The caller retains signature, lifetime, scope and resource policy. Every check |
4 | 4 | refreshes its internal credential and contacts the authority; no decision is cached. |
5 | 5 | Networking dependencies are optional extras so projection remains lightweight. |
| 6 | +
|
| 7 | +Two gRPC transports reach the same RPC. ``MeshGRPCClient`` is the one a current |
| 8 | +host serves: the host's internal tier is cleartext HTTP/2 (h2c) multiplexed onto |
| 9 | +accounts' private ``rest`` endpoint, and in-cluster transport security belongs to |
| 10 | +the service mesh (module-saas-starter ``module/INTERNAL_TRANSPORT.md``). The |
| 11 | +caller dials the address Codefly resolved for that endpoint; the process adds no |
| 12 | +TLS because the mesh has already authenticated both ends. ``GRPCClient`` keeps |
| 13 | +the explicit server-TLS origin for a deployment that terminates TLS on accounts. |
6 | 14 | """ |
7 | 15 |
|
8 | 16 | import asyncio |
@@ -216,39 +224,46 @@ async def aclose(self) -> None: |
216 | 224 | await self.client.aclose() |
217 | 225 |
|
218 | 226 |
|
219 | | -class GRPCClient: |
220 | | - """TLS gRPC on the canonical internal RPC; no retries, fallback or authority cache.""" |
| 227 | +_CHANNEL_OPTIONS = ( |
| 228 | + ("grpc.enable_retries", 0), |
| 229 | + ("grpc.enable_http_proxy", 0), |
| 230 | + ("grpc.max_receive_message_length", 8192), |
| 231 | + ("grpc.max_send_message_length", 65536), |
| 232 | +) |
221 | 233 |
|
222 | | - def __init__(self, origin, credential, *, tls_context=None): |
223 | | - if grpc is None: |
224 | | - raise ImportError( |
225 | | - "Install saas-sdk-python[revision-grpc] for gRPC revision checks" |
226 | | - ) |
227 | | - url = urlsplit(_https_origin(origin)) |
| 234 | + |
| 235 | +def _mesh_address(value: str) -> str: |
| 236 | + """A resolved ``host:port`` with nothing else: no scheme, path or credentials. |
| 237 | +
|
| 238 | + The address is what Codefly resolves for accounts' private ``rest`` endpoint, |
| 239 | + so anything beyond a host and a nonzero port is a configuration error, not a |
| 240 | + form to interpret. |
| 241 | + """ |
| 242 | + if ( |
| 243 | + not isinstance(value, str) |
| 244 | + or "://" in value |
| 245 | + or any(c in value for c in "/?#@\\") |
| 246 | + or any(c.isspace() or ord(c) < 32 for c in value) |
| 247 | + ): |
| 248 | + raise ValueError("Accounts requires a resolved host:port internal address") |
| 249 | + url = urlsplit("//" + value) |
| 250 | + try: |
| 251 | + port = url.port |
| 252 | + except ValueError: |
| 253 | + port = None |
| 254 | + if not url.hostname or not port: |
| 255 | + raise ValueError("Accounts requires a resolved host:port internal address") |
| 256 | + host = f"[{url.hostname}]" if ":" in url.hostname else url.hostname |
| 257 | + return f"{host}:{port}" |
| 258 | + |
| 259 | + |
| 260 | +class _GRPCRevision: |
| 261 | + """The canonical internal RPC over an already opened channel; no retries, |
| 262 | + fallback or authority cache. Subclasses choose only the transport.""" |
| 263 | + |
| 264 | + def _bind(self, channel, credential): |
228 | 265 | self.credential = credential |
229 | | - context = ( |
230 | | - tls_context if tls_context is not None else ssl.create_default_context() |
231 | | - ) |
232 | | - if not context.check_hostname or context.verify_mode != ssl.CERT_REQUIRED: |
233 | | - raise ValueError("Accounts TLS verification cannot be disabled") |
234 | | - # Use the deployment-selected trust roots, not gRPC environment overrides. |
235 | | - roots = b"".join( |
236 | | - ssl.DER_cert_to_PEM_cert(cert).encode("ascii") |
237 | | - for cert in context.get_ca_certs(binary_form=True) |
238 | | - ) |
239 | | - if not roots: |
240 | | - raise ValueError("Accounts requires trusted TLS roots") |
241 | | - host = f"[{url.hostname}]" if ":" in url.hostname else url.hostname |
242 | | - self.channel = grpc.aio.secure_channel( |
243 | | - f"{host}:{url.port or 443}", |
244 | | - grpc.ssl_channel_credentials(root_certificates=roots), |
245 | | - options=( |
246 | | - ("grpc.enable_retries", 0), |
247 | | - ("grpc.enable_http_proxy", 0), |
248 | | - ("grpc.max_receive_message_length", 8192), |
249 | | - ("grpc.max_send_message_length", 65536), |
250 | | - ), |
251 | | - ) |
| 266 | + self.channel = channel |
252 | 267 | self.rpc = self.channel.unary_unary( |
253 | 268 | REVISION_PATH, |
254 | 269 | request_serializer=pb.CheckAuthorizationRevisionRequest.SerializeToString, |
@@ -293,3 +308,58 @@ async def _check(self, claims): |
293 | 308 |
|
294 | 309 | async def aclose(self) -> None: |
295 | 310 | await self.channel.close() |
| 311 | + |
| 312 | + |
| 313 | +class GRPCClient(_GRPCRevision): |
| 314 | + """Server-TLS gRPC on the canonical internal RPC, for an explicit HTTPS origin.""" |
| 315 | + |
| 316 | + def __init__(self, origin, credential, *, tls_context=None): |
| 317 | + if grpc is None: |
| 318 | + raise ImportError( |
| 319 | + "Install saas-sdk-python[revision-grpc] for gRPC revision checks" |
| 320 | + ) |
| 321 | + url = urlsplit(_https_origin(origin)) |
| 322 | + context = ( |
| 323 | + tls_context if tls_context is not None else ssl.create_default_context() |
| 324 | + ) |
| 325 | + if not context.check_hostname or context.verify_mode != ssl.CERT_REQUIRED: |
| 326 | + raise ValueError("Accounts TLS verification cannot be disabled") |
| 327 | + # Use the deployment-selected trust roots, not gRPC environment overrides. |
| 328 | + roots = b"".join( |
| 329 | + ssl.DER_cert_to_PEM_cert(cert).encode("ascii") |
| 330 | + for cert in context.get_ca_certs(binary_form=True) |
| 331 | + ) |
| 332 | + if not roots: |
| 333 | + raise ValueError("Accounts requires trusted TLS roots") |
| 334 | + host = f"[{url.hostname}]" if ":" in url.hostname else url.hostname |
| 335 | + self._bind( |
| 336 | + grpc.aio.secure_channel( |
| 337 | + f"{host}:{url.port or 443}", |
| 338 | + grpc.ssl_channel_credentials(root_certificates=roots), |
| 339 | + options=_CHANNEL_OPTIONS, |
| 340 | + ), |
| 341 | + credential, |
| 342 | + ) |
| 343 | + |
| 344 | + |
| 345 | +class MeshGRPCClient(_GRPCRevision): |
| 346 | + """h2c gRPC to the resolved address of accounts' private ``rest`` endpoint. |
| 347 | +
|
| 348 | + This is the transport a current host serves (``INTERNAL_TRANSPORT.md``): |
| 349 | + the mesh authenticates both workloads and encrypts the hop, and the process |
| 350 | + adds no TLS of its own. Pass the ``host:port`` Codefly resolved for the |
| 351 | + endpoint, never a URL. Deploy it only where the mesh enforces mutual TLS on |
| 352 | + the hop; outside one, the internal credential would cross the wire in the |
| 353 | + clear. |
| 354 | + """ |
| 355 | + |
| 356 | + def __init__(self, address, credential): |
| 357 | + if grpc is None: |
| 358 | + raise ImportError( |
| 359 | + "Install saas-sdk-python[revision-grpc] for gRPC revision checks" |
| 360 | + ) |
| 361 | + self._bind( |
| 362 | + grpc.aio.insecure_channel(_mesh_address(address), options=_CHANNEL_OPTIONS), |
| 363 | + credential, |
| 364 | + ) |
| 365 | + |
0 commit comments