Skip to content

Commit a1d4093

Browse files
rustyconoverclaude
andcommitted
deps: require vgi-rpc >=0.46.0, migrate to Identity.v1 and named protocols
0.46.0 carries wire-breaking changes, so a >=0.45.1 floor lets a deployment resolve a 0.45.x client against a 0.46.0 peer and fail at runtime rather than at install time. Raise the floor and relock. Two things in this repo broke against the new release: Identity moved from HTTP to the RPC layer. `vgi_rpc.http.server._introspect` is gone; `TokenIdentity` now lives in `vgi_rpc.rpc._token_identity` and the `TokenResolver` alias was dropped upstream. Introspection is no longer the `POST {prefix}/__introspect_token__` JSON route but the `vgi_rpc.Identity.v1` protocol hosted on the RpcServer, configured via `RpcServer(identity=...)` rather than three `make_wsgi_app` kwargs. So: - vgi.auth re-exports TokenIdentity from the RPC layer and spells out TokenResolver locally. Neither is gated on the `[http]` extra any more, because identity now reaches every transport rather than only HTTP. - serve.create_app builds an IdentityImpl through a new `_build_identity` helper and hands it to RpcServer. Absent-unless-overridden is preserved: no hook means `identity=None` means the protocol is not hosted at all, rather than hosted-and-refusing. - The fail-closed allowlist and rate-limit startup checks are unchanged; they still run before IdentityImpl is constructed, so its own ValueError path stays unreachable and the actionable SystemExit message survives. The protocol name is now the dispatch routing key. The bad_protocol fixture subclasses VgiProtocol to vary the version, and the name defaults to the class name — so subclassing alone renamed the protocol to `BadProtocol`, and the client's request for `VgiProtocol` raised ProtocolNotSupportedError before reaching the version gate the fixture exists to test. Pin `protocol_name` so the version is once again the only thing that varies. The three TestTokenIntrospection cases that drove the retired HTTP route are rewritten against the surface that now exists. Two neighbours had become vacuous — they asserted 404, which is what every path to a removed route returns — so they now assert on hosted protocols instead. Guard behaviour (allowlist, uniform rejection, JWS-shape refusal, rate limiting) is vgi-rpc's and tested there; what is covered here is the wiring this repo owns. Docs updated to describe the protocol rather than the route. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent a63f7a4 commit a1d4093

9 files changed

Lines changed: 231 additions & 117 deletions

File tree

‎CLAUDE.md‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ vgi-client --input data.parquet --function sum_all_columns --worker vgi-fixture-
297297
| `VGI_OAUTH_DEVICE_CODE_CLIENT_ID` | Client ID for device-code flow (optional, URL-safe chars only) |
298298
| `VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET` | Client secret for device-code flow (optional, URL-safe chars only) |
299299
| `VGI_OAUTH_USE_ID_TOKEN` | When `1`/`true`/`yes`, clients use OIDC `id_token` as Bearer instead of `access_token` |
300-
| `VGI_INTROSPECT_PRINCIPALS` | Comma-separated principals permitted to call `__introspect_token__`. Required — no permissive default — whenever the worker implements `resolve_token()` (see below) |
300+
| `VGI_INTROSPECT_PRINCIPALS` | Comma-separated principals permitted to call `vgi_rpc.Identity.v1`'s `introspect_token`. Required — no permissive default — whenever the worker implements `resolve_token()` (see below) |
301301
| `VGI_INTROSPECT_RATE_LIMIT` | Introspection requests allowed per caller per second (default 20) |
302302
| `VGI_WORKER_ACCESS_LOG_SAMPLE` | Fraction of *successful* calls to keep in the access log, `0.0`–`1.0`. Errors are always kept; the decision is per call, so every record of one stream shares a fate |
303303
| `VGI_WORKER_ACCESS_LOG_ASYNC` | When `1`/`true`/`yes`, emit access-log records from a listener thread. Bounded queue; full means drop, and a crash loses whatever is queued |
@@ -420,10 +420,16 @@ Also available on `Worker.main --http` and the fixture server. Under
420420

421421
### Token Introspection (`resolve_token`)
422422

423-
A worker may optionally expose `POST {prefix}/__introspect_token__`, which
424-
resolves an opaque bearer credential to a principal — for a reverse proxy that
425-
terminates the only public listener and must know the caller's identity before
426-
it can authorize anything.
423+
A worker may optionally host the `vgi_rpc.Identity.v1` protocol, whose
424+
`introspect_token` method resolves an opaque bearer credential to a principal —
425+
for a reverse proxy that terminates the only public listener and must know the
426+
caller's identity before it can authorize anything.
427+
428+
Through vgi-rpc 0.45.x this was an HTTP JSON route, `POST
429+
{prefix}/__introspect_token__`, which meant it existed on exactly one
430+
transport. As of **vgi-rpc 0.46.0** identity lives at the RPC layer, so it
431+
reaches every transport, and a client discovers it through ordinary reflection
432+
(`list_protocols`) rather than by calling and reading an error.
427433

428434
```python
429435
from vgi.auth import AuthUnavailableError, TokenIdentity
@@ -444,9 +450,11 @@ class MyWorker(Worker):
444450
return TokenIdentity(principal=row.principal, token_name=row.label)
445451
```
446452

447-
**The route does not exist until `resolve_token` is overridden** — absent, not
448-
routed-and-refusing. That is what keeps a dependency upgrade from growing a
449-
credential-to-identity oracle on every existing worker.
453+
**The protocol is not hosted until `resolve_token` is overridden** — absent,
454+
not hosted-and-refusing. That is what keeps a dependency upgrade from growing a
455+
credential-to-identity oracle on every existing worker. Only the methods whose
456+
hooks exist are registered, so a worker that resolves credentials but does not
457+
mint grants hosts `introspect_token` and not `issue_grant`.
450458

451459
Enabling it also requires an allowlist of principals permitted to ask, via
452460
`--introspect-principals` or `VGI_INTROSPECT_PRINCIPALS`. There is no permissive

‎docs/api/auth.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ require `pip install vgi-python[http]`; JWT authentication additionally requires
1818
`principal` plus an optional `token_name`. It lives in a private `vgi-rpc` module, so `vgi.auth`
1919
re-exports it — a worker that implements `resolve_token` never has to name a private import path.
2020

21+
As of vgi-rpc 0.46.0 introspection is the `vgi_rpc.Identity.v1` protocol hosted on the RPC server,
22+
not an HTTP JSON route, so it reaches every transport rather than only HTTP. `TokenIdentity` and
23+
`TokenResolver` are correspondingly no longer gated on the `[http]` extra.
24+
2125
## Secret protocol
2226

2327
::: vgi.secret_protocol

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ dependencies = [
4040
"pyarrow",
4141
"typer>=0.9",
4242
"platformdirs",
43-
"vgi-rpc>=0.45.1",
43+
"vgi-rpc>=0.46.0",
4444
"httpx2>=2.9.1",
4545
]
4646

‎tests/test_serve.py‎

Lines changed: 107 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -501,97 +501,137 @@ def test_override_is_detected(self) -> None:
501501
assert resolver("nope") is None
502502

503503

504+
def _introspector_ctx(principal: str) -> object:
505+
"""Build a ``CallContext`` for an authenticated caller named *principal*.
506+
507+
The identity guards read the caller off ``ctx.auth``, so this is the whole
508+
input they need: the allowlist check compares ``auth.principal`` against
509+
the configured principals and refuses anything not authenticated.
510+
"""
511+
from vgi_rpc.rpc import AuthContext, CallContext
512+
513+
return CallContext(
514+
auth=AuthContext(domain="test", authenticated=True, principal=principal),
515+
emit_client_log=lambda *a, **k: None,
516+
)
517+
518+
519+
def _hosted_protocols(worker_cls: type[Worker]) -> frozenset[str]:
520+
"""Return the protocol names the server built for *worker_cls* hosts.
521+
522+
Built the way :func:`vgi.serve.create_app` builds it, so this observes the
523+
real routing table rather than a re-derivation of it. The protocol name is
524+
the dispatch routing key as of vgi-rpc 0.46.0, so "is identity hosted" is
525+
exactly "is its name in here".
526+
"""
527+
from vgi_rpc.rpc import RpcServer
528+
529+
from vgi.protocol import VgiProtocol
530+
from vgi.serve import _build_identity
531+
from vgi.worker import _get_vgi_version
532+
533+
server = RpcServer(
534+
VgiProtocol,
535+
worker_cls(quiet=True),
536+
enable_describe=False,
537+
server_version=_get_vgi_version(),
538+
identity=_build_identity(worker_cls, None, None),
539+
)
540+
return frozenset(server.bindings)
541+
542+
504543
class TestTokenIntrospection:
505-
"""``POST /__introspect_token__`` — absent unless a worker implements it."""
544+
"""``vgi_rpc.Identity.v1`` — unhosted unless a worker implements the hook.
545+
546+
Identity was an HTTP JSON route (``POST {prefix}/__introspect_token__``)
547+
through vgi-rpc 0.45.x. As of 0.46.0 it is an RPC-layer protocol hosted on
548+
the ``RpcServer``, so it reaches every transport rather than only HTTP and
549+
a client discovers it through reflection rather than by calling and reading
550+
an error. What this repo owns — and what these tests cover — is the
551+
*wiring*: a ``resolve_token`` override becomes an ``IdentityImpl`` carrying
552+
the configured allowlist, and nothing at all when the hook is absent. The
553+
guards themselves (allowlist enforcement, uniform rejection, JWS-shape
554+
refusal, rate limiting) live in vgi-rpc and are tested there.
555+
"""
506556

507557
@staticmethod
508-
def _client(worker_cls: type[Worker], **env: str) -> object:
509-
import falcon.testing
510-
511-
from vgi.serve import _resolve_authenticate
558+
def _identity(worker_cls: type[Worker], **env: str) -> object | None:
559+
from vgi.serve import _build_identity
512560

513561
with pytest.MonkeyPatch.context() as mp:
514-
for key in ("VGI_INTROSPECT_PRINCIPALS", "VGI_INTROSPECT_RATE_LIMIT", "VGI_BEARER_TOKENS"):
562+
for key in ("VGI_INTROSPECT_PRINCIPALS", "VGI_INTROSPECT_RATE_LIMIT"):
515563
mp.delenv(key, raising=False)
516564
for key, value in env.items():
517565
mp.setenv(key, value)
518-
app = create_app(
519-
worker_cls,
520-
prefix="/vgi",
521-
describe=False,
522-
authenticate=_resolve_authenticate(),
523-
)
524-
return falcon.testing.TestClient(app)
566+
return _build_identity(worker_cls, None, None)
525567

526-
def test_route_absent_without_hook(self) -> None:
527-
"""Not "routed and refusing" — absent.
568+
def test_identity_absent_without_hook(self) -> None:
569+
"""Not "hosted and refusing" — absent.
528570
529571
This is the property that keeps a dependency upgrade from silently
530572
growing a credential-to-identity oracle on every existing worker.
531573
"""
532-
client = self._client(_SingleWorker)
533-
resp = client.simulate_post("/vgi/__introspect_token__", json={"token": "x"}) # type: ignore[attr-defined]
534-
assert resp.status_code == 404
574+
assert self._identity(_SingleWorker) is None
535575

536-
def test_capability_not_advertised_without_hook(self) -> None:
537-
"""A proxy preflighting at boot learns this worker cannot answer."""
538-
app = create_app(_SingleWorker, prefix="/vgi", describe=False)
539-
assert "vgi-token-introspection" not in _capability_headers(app)
576+
def test_protocol_not_hosted_without_hook(self) -> None:
577+
"""The server built for such a worker hosts no identity protocol."""
578+
assert "vgi_rpc.Identity.v1" not in _hosted_protocols(_SingleWorker)
579+
580+
def test_identity_built_with_hook(self) -> None:
581+
"""Implementing the hook is what builds the implementation."""
582+
identity = self._identity(_IntrospectingWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a")
583+
assert identity is not None
584+
585+
def test_only_offered_methods_are_hosted(self) -> None:
586+
"""A worker that resolves but does not mint offers exactly one method.
540587
541-
def test_capability_advertised_with_hook(self) -> None:
542-
"""A proxy can discover support at boot rather than at first login."""
588+
The protocol a server hosts describes what it actually does, so a
589+
client learns ``issue_grant`` is unavailable from reflection rather
590+
than from an error.
591+
"""
592+
identity = self._identity(_IntrospectingWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a")
593+
assert identity is not None
594+
assert identity.offered_methods() == frozenset({"introspect_token"}) # type: ignore[attr-defined]
595+
596+
def test_protocol_hosted_with_hook(self) -> None:
597+
"""A client preflighting via reflection discovers the worker can answer."""
543598
with pytest.MonkeyPatch.context() as mp:
544599
mp.setenv("VGI_INTROSPECT_PRINCIPALS", "proxy-a")
545-
app = create_app(_IntrospectingWorker, prefix="/vgi", describe=False)
546-
assert _capability_headers(app)["vgi-token-introspection"] == "true"
547-
548-
def test_allowlisted_caller_resolves(self) -> None:
549-
"""The endpoint answers with a principal and never with claims."""
550-
client = self._client(
551-
_IntrospectingWorker,
552-
VGI_INTROSPECT_PRINCIPALS="proxy-a",
553-
VGI_BEARER_TOKENS="ptok=proxy-a",
554-
)
555-
resp = client.simulate_post( # type: ignore[attr-defined]
556-
"/vgi/__introspect_token__",
557-
json={"token": "good-token"},
558-
headers={"Authorization": "Bearer ptok"},
559-
)
560-
assert resp.status_code == 200
561-
assert resp.json == {"principal": "alice", "token_name": "deploy-key-1", "ttl_seconds": 300}
562-
assert "claims" not in resp.json
563-
564-
def test_unresolvable_token_is_404(self) -> None:
565-
"""Unknown credential and unknown route answer alike, on purpose."""
566-
client = self._client(
567-
_IntrospectingWorker,
568-
VGI_INTROSPECT_PRINCIPALS="proxy-a",
569-
VGI_BEARER_TOKENS="ptok=proxy-a",
570-
)
571-
resp = client.simulate_post( # type: ignore[attr-defined]
572-
"/vgi/__introspect_token__",
573-
json={"token": "nope"},
574-
headers={"Authorization": "Bearer ptok"},
575-
)
576-
assert resp.status_code == 404
600+
hosted = _hosted_protocols(_IntrospectingWorker)
601+
assert "vgi_rpc.Identity.v1" in hosted
602+
603+
def test_resolver_answers_through_the_implementation(self) -> None:
604+
"""The worker's own lookup is what the hosted protocol calls."""
605+
identity = self._identity(_IntrospectingWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a")
606+
assert identity is not None
607+
resolved = identity.introspect_token("good-token", _introspector_ctx("proxy-a")) # type: ignore[attr-defined]
608+
assert resolved.principal == "alice"
609+
assert resolved.token_name == "deploy-key-1"
610+
# Never claims: a pass-through claims field would let a worker choose
611+
# its caller's tenant routing and policy branch.
612+
assert not hasattr(resolved, "claims")
613+
614+
def test_unresolvable_token_is_refused(self) -> None:
615+
"""Unknown, expired and malformed are deliberately one answer."""
616+
from vgi_rpc.rpc._token_identity import TokenUnresolvedError
617+
618+
identity = self._identity(_IntrospectingWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a")
619+
assert identity is not None
620+
with pytest.raises(TokenUnresolvedError):
621+
identity.introspect_token("nope", _introspector_ctx("proxy-a")) # type: ignore[attr-defined]
577622

578623
def test_non_allowlisted_caller_refused(self) -> None:
579624
"""Authenticating is not the same capability as introspecting.
580625
581626
Without this, any user holding a valid credential could resolve any
582627
other user's credential to its owner.
583628
"""
584-
client = self._client(
585-
_IntrospectingWorker,
586-
VGI_INTROSPECT_PRINCIPALS="proxy-a",
587-
VGI_BEARER_TOKENS="ptok=proxy-a,utok=user-b",
588-
)
589-
resp = client.simulate_post( # type: ignore[attr-defined]
590-
"/vgi/__introspect_token__",
591-
json={"token": "good-token"},
592-
headers={"Authorization": "Bearer utok"},
593-
)
594-
assert resp.status_code == 403
629+
from vgi_rpc.rpc._token_identity import IntrospectionRefusedError
630+
631+
identity = self._identity(_IntrospectingWorker, VGI_INTROSPECT_PRINCIPALS="proxy-a")
632+
assert identity is not None
633+
with pytest.raises(IntrospectionRefusedError):
634+
identity.introspect_token("good-token", _introspector_ctx("user-b")) # type: ignore[attr-defined]
595635

596636
def test_hook_without_allowlist_refuses_to_start(self) -> None:
597637
"""Fail closed and loud, rather than defaulting to an open oracle."""

‎uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vgi/_test_fixtures/bad_protocol.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@
4444
class BadProtocol(VgiProtocol):
4545
"""VgiProtocol surface with a deliberately incompatible version."""
4646

47+
# The *only* thing this fixture varies is the version. As of vgi-rpc
48+
# 0.46.0 the protocol name is the dispatch routing key, and it defaults to
49+
# the class name -- so without this line subclassing alone renames the
50+
# protocol to ``BadProtocol``, the client's request for ``VgiProtocol``
51+
# finds no such protocol hosted, and the caller gets
52+
# ``ProtocolNotSupportedError`` before the version gate it came to test
53+
# ever runs.
54+
protocol_name: ClassVar[str] = "VgiProtocol"
4755
protocol_version: ClassVar[str] = BAD_PROTOCOL_VERSION
4856

4957

‎vgi/auth.py‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
bearer_authenticate, bearer_authenticate_static, chain_authenticate,
1010
OAuthResourceMetadata, AuthUnavailableError
1111
12-
Token introspection (requires ``vgi[http]``):
12+
Token introspection (always available):
1313
TokenIdentity, TokenResolver
1414
1515
JWT auth (requires ``vgi[oauth]``):
@@ -19,12 +19,28 @@
1919
from __future__ import annotations
2020

2121
import contextlib
22+
from collections.abc import Callable
2223

2324
from vgi_rpc.rpc import AuthContext, CallContext
2425

26+
# ``vgi_rpc.Identity.v1`` lives at the RPC layer as of vgi-rpc 0.46.0 -- it was
27+
# an HTTP JSON route (``POST {prefix}/__introspect_token__``) through 0.45.x,
28+
# which meant it existed on exactly one transport. So this is no longer gated
29+
# on the ``http`` extra. Still not re-exported by ``vgi_rpc.rpc`` itself, so
30+
# the private module remains the only import path; re-exported here so a worker
31+
# that implements ``resolve_token`` never has to name one.
32+
from vgi_rpc.rpc._token_identity import TokenIdentity
33+
34+
#: What :meth:`vgi.worker.Worker.resolve_token` is. Upstream dropped its own
35+
#: alias when identity moved to the RPC layer; it is spelled out here so the
36+
#: name ``vgi.auth.TokenResolver`` keeps working.
37+
TokenResolver = Callable[[str], "TokenIdentity | None"]
38+
2539
__all__ = [
2640
"AuthContext",
2741
"CallContext",
42+
"TokenIdentity",
43+
"TokenResolver",
2844
]
2945

3046
# HTTP auth helpers — available when vgi[http] is installed.
@@ -41,16 +57,9 @@
4157
parse_device_code_client_secret,
4258
)
4359

44-
# Not re-exported by ``vgi_rpc.http`` itself, so the private module is the
45-
# only import path. Re-exported here so a worker that implements
46-
# ``resolve_token`` never has to name a private module.
47-
from vgi_rpc.http.server._introspect import TokenIdentity, TokenResolver # noqa: F401
48-
4960
__all__ += [
5061
"AuthUnavailableError",
5162
"OAuthResourceMetadata",
52-
"TokenIdentity",
53-
"TokenResolver",
5463
"bearer_authenticate",
5564
"bearer_authenticate_static",
5665
"chain_authenticate",

0 commit comments

Comments
 (0)