Skip to content

Commit 28f155b

Browse files
feat: reach the host's internal revision RPC over the mesh (h2c)
The host serves WorkContextService.CheckAuthorizationRevision as h2c multiplexed onto accounts' private rest endpoint and leaves in-cluster transport security to the mesh (module-saas-starter module/INTERNAL_TRANSPORT.md, #859/#864). GRPCClient requires an https origin and CA roots, which that contract calls unsatisfiable. Add MeshGRPCClient: it dials the host:port Codefly resolved, with no TLS of its own, and shares GRPCClient's check, budget and error mapping. GRPCClient is unchanged. Add WorkContextUnavailable, a WorkContextError subclass JWKSVerifier raises when the key set cannot be fetched or read, so a consumer can report a verifier outage as unavailable instead of as an invalid capability. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 6071df5 commit 28f155b

5 files changed

Lines changed: 211 additions & 54 deletions

File tree

‎README.md‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ Verification enforces the two-segment shape, the signature, structural
114114
validation (including the proto's `min_len` constraints on optional fields and
115115
monotonic scope attenuation), the time window, and the caller's expectations,
116116
raising `WorkContextError` (or `WorkContextDenied` from `require_scope`) on any
117-
failure. Replay policy is reported on the claims; enforcing single-use
117+
failure. When the key set itself cannot be fetched or read, `JWKSVerifier` raises
118+
`WorkContextUnavailable`, a `WorkContextError` subclass, so a consumer can report
119+
an authority outage as unavailable rather than as an invalid capability. Replay policy is reported on the claims; enforcing single-use
118120
consumption still requires a durable replay store the caller owns.
119121

120122
## Current authorization revision
@@ -125,16 +127,25 @@ actor's original scopes, including resource IDs and revision. The consumer keeps
125127
signature/lifetime verification, action/resource policy and public error mapping.
126128

127129
```python
128-
from saas_sdk.authorization_revision import GRPCClient, RevisionDenied, RevisionUnavailable
130+
from saas_sdk.authorization_revision import MeshGRPCClient, RevisionDenied, RevisionUnavailable
129131

130-
client = GRPCClient("https://accounts.internal:50051", current_internal_credential)
132+
# The host:port Codefly resolved for accounts' private `rest` endpoint.
133+
client = MeshGRPCClient(resolved_accounts_rest_address, current_internal_credential)
131134
try:
132135
await client.check(verified_claims)
133136
finally:
134137
await client.aclose()
135138
```
136139

137-
Install `saas-sdk-python[revision-grpc]` for the canonical TLS gRPC client. Where an
140+
A current host serves this RPC as cleartext HTTP/2 (h2c) multiplexed onto accounts'
141+
private `rest` endpoint, and leaves in-cluster transport security to the service
142+
mesh (module-saas-starter `module/INTERNAL_TRANSPORT.md`). `MeshGRPCClient` dials
143+
that resolved `host:port` and adds no TLS; it refuses a URL, a path or credentials
144+
in the address. Use it only where the mesh enforces mutual TLS on the hop.
145+
`GRPCClient("https://…", …)` remains for a deployment that terminates server TLS on
146+
accounts itself.
147+
148+
Install `saas-sdk-python[revision-grpc]` for either gRPC client. Where an
138149
endpoint explicitly serves Connect, install `[revision-connect]` and select
139150
`ConnectClient`; selecting that compatibility transport is a composition choice,
140151
never an automatic fallback. `revision_request(claims)` needs neither networking

‎src/saas_sdk/authorization_revision.py‎

Lines changed: 101 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
The caller retains signature, lifetime, scope and resource policy. Every check
44
refreshes its internal credential and contacts the authority; no decision is cached.
55
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.
614
"""
715

816
import asyncio
@@ -216,39 +224,46 @@ async def aclose(self) -> None:
216224
await self.client.aclose()
217225

218226

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+
)
221233

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):
228265
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
252267
self.rpc = self.channel.unary_unary(
253268
REVISION_PATH,
254269
request_serializer=pb.CheckAuthorizationRevisionRequest.SerializeToString,
@@ -293,3 +308,58 @@ async def _check(self, claims):
293308

294309
async def aclose(self) -> None:
295310
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+

‎src/saas_sdk/work_context.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
"MAX_TTL",
6363
"WorkContextError",
6464
"WorkContextDenied",
65+
"WorkContextUnavailable",
6566
"WorkContextMintError",
6667
"WorkScope",
6768
"WorkActor",
@@ -133,6 +134,13 @@ class WorkContextDenied(WorkContextError):
133134
told apart from invalid capabilities."""
134135

135136

137+
class WorkContextUnavailable(WorkContextError):
138+
"""The published key set could not be fetched or read, so no presented
139+
token was judged. Distinct from :class:`WorkContextError` so a verifier
140+
outage can be reported as the authority being unavailable rather than as
141+
the caller presenting an invalid capability; both still fail closed."""
142+
143+
136144
@dataclass(frozen=True)
137145
class WorkScope:
138146
resource_kind: str
@@ -409,7 +417,10 @@ def _refresh_unknown(self, observed_generation: int) -> tuple[Verifier, frozense
409417
def _refresh_locked(self) -> tuple[Verifier, frozenset[str], int]:
410418
try:
411419
keys = self._fetch()
412-
verifier = Verifier(keys, now=self._now, clock_skew=self._clock_skew)
420+
try:
421+
verifier = Verifier(keys, now=self._now, clock_skew=self._clock_skew)
422+
except WorkContextError as error:
423+
raise WorkContextUnavailable(str(error)) from None
413424
except WorkContextError as error:
414425
# Open a short backoff window so a failed refresh does not re-hit the
415426
# endpoint on the next request (avoids an outage-driven fetch storm).
@@ -431,17 +442,21 @@ def _fetch(self) -> dict[str, bytes]:
431442
try:
432443
response = self._opener.open(request, timeout=self._request_timeout)
433444
except urllib.error.HTTPError as error:
434-
raise WorkContextError(f"Work Context JWKS returned HTTP {error.code}") from None
445+
raise WorkContextUnavailable(f"Work Context JWKS returned HTTP {error.code}") from None
435446
except (urllib.error.URLError, OSError) as error:
436-
raise WorkContextError(f"fetch Work Context JWKS: {error}") from None
447+
raise WorkContextUnavailable(f"fetch Work Context JWKS: {error}") from None
437448
with response:
438449
content_type = response.headers.get("Content-Type")
439450
if content_type and content_type.split(";", 1)[0].strip().lower() != "application/json":
440-
raise WorkContextError("Work Context JWKS is not application/json")
451+
raise WorkContextUnavailable("Work Context JWKS is not application/json")
441452
payload = response.read(_JWKS_MAX_BYTES + 1)
442453
if len(payload) > _JWKS_MAX_BYTES:
443-
raise WorkContextError(f"Work Context JWKS exceeds {_JWKS_MAX_BYTES} bytes")
444-
return _parse_jwks(payload)
454+
raise WorkContextUnavailable(f"Work Context JWKS exceeds {_JWKS_MAX_BYTES} bytes")
455+
try:
456+
return _parse_jwks(payload)
457+
except WorkContextError as error:
458+
# A malformed key set is the authority's failure, never the caller's.
459+
raise WorkContextUnavailable(str(error)) from None
445460

446461

447462
def _parse_jwks(payload: bytes) -> dict[str, bytes]:

‎tests/test_authorization_revision.py‎

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ async def respond(request):
246246
await client.aclose()
247247

248248

249-
@pytest_asyncio.fixture
250-
async def grpc_endpoint(tmp_path):
249+
@pytest_asyncio.fixture(params=["tls", "mesh"])
250+
async def grpc_endpoint(request, tmp_path):
251251
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
252252
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
253253
now = datetime.now(timezone.utc)
@@ -305,12 +305,19 @@ async def check(request, context):
305305
),
306306
)
307307
)
308-
port = server.add_secure_port(
309-
"localhost:0", grpc.ssl_server_credentials(((key_bytes, cert_bytes),))
310-
)
308+
if request.param == "tls":
309+
port = server.add_secure_port(
310+
"localhost:0", grpc.ssl_server_credentials(((key_bytes, cert_bytes),))
311+
)
312+
else:
313+
# The current host's transport: h2c, with the mesh securing the hop.
314+
port = server.add_insecure_port("localhost:0")
311315
await server.start()
312-
tls = ssl.create_default_context(cadata=cert_bytes.decode())
313-
client = revision.GRPCClient(f"https://localhost:{port}", token, tls_context=tls)
316+
if request.param == "tls":
317+
tls = ssl.create_default_context(cadata=cert_bytes.decode())
318+
client = revision.GRPCClient(f"https://localhost:{port}", token, tls_context=tls)
319+
else:
320+
client = revision.MeshGRPCClient(f"localhost:{port}", token)
314321
try:
315322
yield client, state
316323
finally:
@@ -443,3 +450,41 @@ async def respond(request):
443450
assert cancelled.is_set()
444451
finally:
445452
await client.aclose()
453+
454+
455+
@pytest.mark.parametrize(
456+
"address",
457+
[
458+
"",
459+
"accounts.invalid",
460+
"accounts.invalid:0",
461+
"accounts.invalid:65536",
462+
"accounts.invalid:port",
463+
"http://accounts.invalid:8080",
464+
"https://accounts.invalid:8080",
465+
"//accounts.invalid:8080",
466+
"user@accounts.invalid:8080",
467+
"accounts.invalid:8080/path",
468+
"accounts.invalid:8080?x=1",
469+
"accounts.invalid:8080#x",
470+
"accounts.invalid :8080",
471+
":8080",
472+
],
473+
)
474+
async def test_mesh_address_is_a_resolved_host_and_port(address):
475+
with pytest.raises(ValueError, match="host:port"):
476+
revision.MeshGRPCClient(address, token)
477+
478+
479+
@pytest.mark.parametrize(
480+
"address,target",
481+
[
482+
("accounts.platform-saas.svc.cluster.local:8080", "accounts.platform-saas.svc.cluster.local:8080"),
483+
("127.0.0.1:8080", "127.0.0.1:8080"),
484+
("[::1]:8080", "[::1]:8080"),
485+
],
486+
)
487+
async def test_mesh_address_accepts_resolved_forms(address, target):
488+
assert revision._mesh_address(address) == target
489+
client = revision.MeshGRPCClient(address, token)
490+
await client.aclose()

0 commit comments

Comments
 (0)