|
| 1 | +# Copyright 2025, 2026 Query Farm LLC - https://query.farm |
| 2 | + |
| 3 | +"""Identity credentials accepted as HTTP bearers: sealed grants and ``resolve_token``. |
| 4 | +
|
| 5 | +vgi-rpc's ``Identity.v1`` mints grants "presented later by unattended |
| 6 | +automation as an ordinary bearer". These tests close that loop through |
| 7 | +vgi-python's own HTTP surface (``create_app``), not vgi-rpc in isolation: |
| 8 | +
|
| 9 | +- with ``VGI_RPC_GRANT_KEYS`` set, ``issue_grant`` mints a sealed grant and a |
| 10 | + ``vgi.v2`` call presenting it is authenticated as the grant's owner; |
| 11 | +- a worker that overrides ``resolve_token`` has it consulted for bearers; |
| 12 | +- the shared vectors (``vgi_rpc/conformance/grant_token_vectors.json``) are |
| 13 | + accepted and rejected through the full chain; |
| 14 | +- VGI's proxy-proof gate still guards every request when grants are on. |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import base64 |
| 20 | +import json |
| 21 | +import time |
| 22 | +import types |
| 23 | +from collections.abc import Iterator |
| 24 | +from importlib.resources import files |
| 25 | +from typing import Any |
| 26 | + |
| 27 | +import pyarrow as pa |
| 28 | +import pytest |
| 29 | +from vgi_rpc.grants import GRANT_KEYS_ENV, GrantKeys, mint_grant_token |
| 30 | +from vgi_rpc.http import http_connect |
| 31 | +from vgi_rpc.http._testing import _SyncTestClient |
| 32 | +from vgi_rpc.rpc import AuthContext |
| 33 | +from vgi_rpc.rpc._token_identity import Identity |
| 34 | + |
| 35 | +from tests.test_serve import _PeerAuthWorker |
| 36 | +from vgi.arguments import Arguments |
| 37 | +from vgi.auth import TokenIdentity |
| 38 | +from vgi.client import Client, ClientError |
| 39 | +from vgi.rpc_server import build_rpc_server, resolve_grant_keys |
| 40 | +from vgi.serve import create_app |
| 41 | + |
| 42 | +_KEY_B64 = base64.b64encode(bytes(range(32))).decode() |
| 43 | +_OTHER_KEY_B64 = base64.b64encode(bytes(range(32, 64))).decode() |
| 44 | +_SIGNING_KEY = b"grant-auth-test-signing-key-0001" |
| 45 | +_IDENTITY = "vgi_rpc.Identity.v1" |
| 46 | +_GRANT_ENV = (GRANT_KEYS_ENV, "VGI_RPC_GRANT_AUDIENCE", "VGI_RPC_GRANT_MAX_TTL_SECONDS") |
| 47 | + |
| 48 | +_VECTORS = json.loads(files("vgi_rpc.conformance").joinpath("grant_token_vectors.json").read_text(encoding="utf-8")) |
| 49 | + |
| 50 | + |
| 51 | +@pytest.fixture(autouse=True) |
| 52 | +def _clean_env(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: |
| 53 | + """No ambient grant, auth or allowlist configuration leaks into a test.""" |
| 54 | + for key in (*_GRANT_ENV, "VGI_INTROSPECT_PRINCIPALS", "VGI_BEARER_TOKENS", "VGI_JWT_ISSUER"): |
| 55 | + monkeypatch.delenv(key, raising=False) |
| 56 | + for key in ("VGI_PROXY_PROOF_MODE", "VGI_PROXY_PROOF_ORIGIN_ID", "VGI_PROXY_PROOF_SECRETS"): |
| 57 | + monkeypatch.delenv(key, raising=False) |
| 58 | + yield |
| 59 | + |
| 60 | + |
| 61 | +def _header_authenticate(req: Any) -> AuthContext: |
| 62 | + """Deployment authenticator: ``X-Test-Principal`` with a fresh ``auth_time``. |
| 63 | +
|
| 64 | + Raises ``ValueError`` for a bare bearer, as the chain contract requires, so |
| 65 | + grants and ``resolve_token`` get to answer it. |
| 66 | + """ |
| 67 | + principal = req.get_header("X-Test-Principal") |
| 68 | + if principal: |
| 69 | + return AuthContext(domain="test", authenticated=True, principal=principal, claims={"auth_time": time.time()}) |
| 70 | + if req.get_header("Authorization"): |
| 71 | + raise ValueError("not a test-principal request") |
| 72 | + return AuthContext.anonymous() |
| 73 | + |
| 74 | + |
| 75 | +def _whoami(app: Any, *, bearer: str) -> str: |
| 76 | + """Call ``peer_auth_echo`` (a vgi.v2 scalar function) and return its principal.""" |
| 77 | + client = _SyncTestClient(app, default_headers={"Authorization": f"Bearer {bearer}"}) |
| 78 | + with Client.from_http("http://vgi.test", httpx_client=client) as vgi: |
| 79 | + out = list( |
| 80 | + vgi.scalar_function( |
| 81 | + function_name="peer_auth_echo", |
| 82 | + schema_path=["main"], |
| 83 | + arguments=Arguments(positional=(pa.scalar("value"),)), |
| 84 | + input=iter((pa.record_batch({"value": pa.array([1], type=pa.int64())}),)), |
| 85 | + ) |
| 86 | + ) |
| 87 | + return str(out[0].column("result")[0].as_py()) |
| 88 | + |
| 89 | + |
| 90 | +def _app(worker: type = _PeerAuthWorker, **kwargs: Any) -> Any: |
| 91 | + return create_app(worker, describe=False, signing_key=_SIGNING_KEY, **kwargs) |
| 92 | + |
| 93 | + |
| 94 | +class TestGrantKeyConfiguration: |
| 95 | + """Opt-in, from the environment or ``--grant-key``; malformed keys stop startup.""" |
| 96 | + |
| 97 | + def test_no_key_means_grants_off(self) -> None: |
| 98 | + """No key means grants off.""" |
| 99 | + assert resolve_grant_keys() is None |
| 100 | + assert _IDENTITY not in build_rpc_server(_PeerAuthWorker(quiet=True), transport="http").bindings |
| 101 | + |
| 102 | + def test_env_keys_parse(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 103 | + """Env keys parse.""" |
| 104 | + monkeypatch.setenv(GRANT_KEYS_ENV, f"{_KEY_B64},{_OTHER_KEY_B64}") |
| 105 | + monkeypatch.setenv("VGI_RPC_GRANT_AUDIENCE", "aud") |
| 106 | + keys = resolve_grant_keys() |
| 107 | + assert keys is not None |
| 108 | + assert keys.keys == (bytes(range(32)), bytes(range(32, 64))) |
| 109 | + assert keys.audience == "aud" |
| 110 | + |
| 111 | + def test_explicit_keys_win_over_env(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 112 | + """Explicit keys win over env.""" |
| 113 | + monkeypatch.setenv(GRANT_KEYS_ENV, _OTHER_KEY_B64) |
| 114 | + keys = resolve_grant_keys([_KEY_B64]) |
| 115 | + assert keys is not None |
| 116 | + assert keys.keys == (bytes(range(32)),) |
| 117 | + |
| 118 | + @pytest.mark.parametrize("bad", ["not-base64!", base64.b64encode(b"short").decode()]) |
| 119 | + def test_malformed_key_refuses_to_start(self, monkeypatch: pytest.MonkeyPatch, bad: str) -> None: |
| 120 | + """Malformed key refuses to start.""" |
| 121 | + monkeypatch.setenv(GRANT_KEYS_ENV, bad) |
| 122 | + with pytest.raises(SystemExit): |
| 123 | + _app() |
| 124 | + |
| 125 | + def test_keys_alone_host_issue_grant_without_an_allowlist(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 126 | + """Minting is not an oracle, so the introspection allowlist rule does not apply.""" |
| 127 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 128 | + server = build_rpc_server(_PeerAuthWorker(quiet=True), transport="http", grant_keys=resolve_grant_keys()) |
| 129 | + assert server.bindings[_IDENTITY].methods.keys() == {"issue_grant"} |
| 130 | + |
| 131 | + def test_keys_are_http_only(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 132 | + """Keys are http only.""" |
| 133 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 134 | + server = build_rpc_server(_PeerAuthWorker(quiet=True), transport="unix", grant_keys=resolve_grant_keys()) |
| 135 | + assert _IDENTITY not in server.bindings |
| 136 | + |
| 137 | + |
| 138 | +class TestSealedGrantEndToEnd: |
| 139 | + """issue_grant with fresh auth, then a vgi.v2 call with ``Bearer <grant>``, as the owner.""" |
| 140 | + |
| 141 | + def test_minted_grant_authenticates_as_its_owner(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 142 | + """Minted grant authenticates as its owner.""" |
| 143 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 144 | + app = _app(authenticate=_header_authenticate) |
| 145 | + |
| 146 | + minter = _SyncTestClient(app, default_headers={"X-Test-Principal": "alice@example.com"}) |
| 147 | + with http_connect(Identity, client=minter) as identity: # type: ignore[type-abstract] |
| 148 | + grant = identity.issue_grant(purpose="nightly-report", scopes=["read"], ttl_seconds=600) |
| 149 | + assert grant.token.startswith("vgig1.") |
| 150 | + |
| 151 | + assert _whoami(app, bearer=grant.token) == "alice@example.com" |
| 152 | + |
| 153 | + def test_tampered_grant_is_refused(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 154 | + """Tampered grant is refused.""" |
| 155 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 156 | + keys = GrantKeys.parse([_KEY_B64]) |
| 157 | + token, _ = mint_grant_token(keys, principal="alice", scopes=[], purpose="p", ttl_seconds=60) |
| 158 | + tampered = token[:-2] + ("A" if token[-2] != "A" else "B") + token[-1] |
| 159 | + with pytest.raises(ClientError): |
| 160 | + _whoami(_app(authenticate=_header_authenticate), bearer=tampered) |
| 161 | + |
| 162 | + def test_unknown_bearer_without_grants_configured_stays_unaccepted(self) -> None: |
| 163 | + """No keys, no hook: nothing changes -- the deployment authenticator decides.""" |
| 164 | + app = _app(authenticate=_header_authenticate) |
| 165 | + with pytest.raises(ClientError): |
| 166 | + _whoami(app, bearer="vgig1.AAAA") |
| 167 | + |
| 168 | + |
| 169 | +class _ResolvingWorker(_PeerAuthWorker): |
| 170 | + """Resolves one opaque credential; counts calls so routing is observable.""" |
| 171 | + |
| 172 | + calls: list[str] = [] |
| 173 | + |
| 174 | + @classmethod |
| 175 | + def resolve_token(cls, token: str) -> TokenIdentity | None: |
| 176 | + """Resolve token.""" |
| 177 | + cls.calls.append(token) |
| 178 | + return TokenIdentity(principal="svc-reporting") if token == "opaque-api-key" else None |
| 179 | + |
| 180 | + |
| 181 | +class TestResolveTokenBearer: |
| 182 | + """A worker's resolve_token is consulted for bearers it did not mint.""" |
| 183 | + |
| 184 | + @pytest.fixture |
| 185 | + def app(self, monkeypatch: pytest.MonkeyPatch) -> Any: |
| 186 | + """App.""" |
| 187 | + monkeypatch.setenv("VGI_INTROSPECT_PRINCIPALS", "proxy-a") |
| 188 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 189 | + _ResolvingWorker.calls = [] |
| 190 | + return _app(_ResolvingWorker, authenticate=_header_authenticate) |
| 191 | + |
| 192 | + def test_resolved_bearer_authenticates(self, app: Any) -> None: |
| 193 | + """Resolved bearer authenticates.""" |
| 194 | + assert _whoami(app, bearer="opaque-api-key") == "svc-reporting" |
| 195 | + |
| 196 | + def test_unresolved_bearer_is_refused(self, app: Any) -> None: |
| 197 | + """Unresolved bearer is refused.""" |
| 198 | + with pytest.raises(ClientError): |
| 199 | + _whoami(app, bearer="unknown-key") |
| 200 | + |
| 201 | + def test_a_bad_grant_never_reaches_the_resolver(self, app: Any) -> None: |
| 202 | + """A bad grant never reaches the resolver.""" |
| 203 | + with pytest.raises(ClientError): |
| 204 | + _whoami(app, bearer="vgig1.AAAA") |
| 205 | + assert "vgig1.AAAA" not in _ResolvingWorker.calls |
| 206 | + |
| 207 | + def test_allowlist_rule_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 208 | + """Allowlist rule unchanged.""" |
| 209 | + monkeypatch.delenv("VGI_INTROSPECT_PRINCIPALS", raising=False) |
| 210 | + with pytest.raises(SystemExit): |
| 211 | + _app(_ResolvingWorker) |
| 212 | + |
| 213 | + |
| 214 | +def _vector_env(monkeypatch: pytest.MonkeyPatch, verify_keys: list[str], audience: str, max_ttl: int) -> None: |
| 215 | + monkeypatch.setenv(GRANT_KEYS_ENV, ",".join(verify_keys)) |
| 216 | + monkeypatch.setenv("VGI_RPC_GRANT_AUDIENCE", audience) |
| 217 | + monkeypatch.setenv("VGI_RPC_GRANT_MAX_TTL_SECONDS", str(max_ttl)) |
| 218 | + |
| 219 | + |
| 220 | +def _at(monkeypatch: pytest.MonkeyPatch, now: float) -> None: |
| 221 | + """Pin the grant verifier's clock without touching the process clock.""" |
| 222 | + import vgi_rpc.grants |
| 223 | + |
| 224 | + monkeypatch.setattr(vgi_rpc.grants, "time", types.SimpleNamespace(time=lambda: now)) |
| 225 | + |
| 226 | + |
| 227 | +class TestSharedVectors: |
| 228 | + """The shared vectors, accepted and rejected through create_app's whole chain.""" |
| 229 | + |
| 230 | + @pytest.mark.parametrize("case", _VECTORS["mint"], ids=lambda c: c["name"]) |
| 231 | + def test_minted_vector_authenticates(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None: |
| 232 | + """Minted vector authenticates.""" |
| 233 | + verify = [case["minting_key_b64"], *_VECTORS["defaults"]["verify_keys_b64"]] |
| 234 | + _vector_env(monkeypatch, list(dict.fromkeys(verify)), case["audience"], case["max_ttl_seconds"]) |
| 235 | + _at(monkeypatch, case["now"] + 1) |
| 236 | + assert _whoami(_app(), bearer=case["token"]) == case["claims"]["principal"] |
| 237 | + |
| 238 | + @pytest.mark.parametrize("case", _VECTORS["accept"], ids=lambda c: c["name"]) |
| 239 | + def test_accept_case(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None: |
| 240 | + """Accept case.""" |
| 241 | + d = _VECTORS["defaults"] |
| 242 | + _vector_env( |
| 243 | + monkeypatch, |
| 244 | + case.get("verify_keys_b64", d["verify_keys_b64"]), |
| 245 | + case.get("audience", d["audience"]), |
| 246 | + case.get("max_ttl_seconds", d["max_ttl_seconds"]), |
| 247 | + ) |
| 248 | + _at(monkeypatch, case.get("now", d["now"])) |
| 249 | + assert _whoami(_app(), bearer=case["token"]) != "anonymous" |
| 250 | + |
| 251 | + @pytest.mark.parametrize("case", _VECTORS["reject"], ids=lambda c: c["name"]) |
| 252 | + def test_reject_case(self, monkeypatch: pytest.MonkeyPatch, case: dict[str, Any]) -> None: |
| 253 | + """Reject case.""" |
| 254 | + d = _VECTORS["defaults"] |
| 255 | + _vector_env( |
| 256 | + monkeypatch, |
| 257 | + case.get("verify_keys_b64", d["verify_keys_b64"]), |
| 258 | + case.get("audience", d["audience"]), |
| 259 | + case.get("max_ttl_seconds", d["max_ttl_seconds"]), |
| 260 | + ) |
| 261 | + _at(monkeypatch, case.get("now", d["now"])) |
| 262 | + with pytest.raises(ClientError): |
| 263 | + _whoami(_app(), bearer=case["token"]) |
| 264 | + |
| 265 | + |
| 266 | +class TestProxyProofGateStillGuards: |
| 267 | + """Grants go inside VGI's proxy-proof gate, never beside it.""" |
| 268 | + |
| 269 | + @pytest.fixture |
| 270 | + def gated(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 271 | + """Gated.""" |
| 272 | + monkeypatch.setenv("VGI_PROXY_PROOF_MODE", "require") |
| 273 | + monkeypatch.setenv("VGI_PROXY_PROOF_ORIGIN_ID", "worker-a") |
| 274 | + monkeypatch.setenv("VGI_PROXY_PROOF_SECRETS", "k:" + "11" * 32) |
| 275 | + monkeypatch.setenv(GRANT_KEYS_ENV, _KEY_B64) |
| 276 | + |
| 277 | + def test_gated_worker_with_grants_starts(self, gated: None) -> None: |
| 278 | + """vgi-rpc refuses to OR grants beside a proxy gate; create_app composes inside it.""" |
| 279 | + from vgi.serve import _resolve_authenticate |
| 280 | + |
| 281 | + _app(authenticate=_resolve_authenticate()) |
| 282 | + |
| 283 | + def test_valid_grant_without_proof_is_refused(self, gated: None) -> None: |
| 284 | + """Valid grant without proof is refused.""" |
| 285 | + from vgi.serve import _resolve_authenticate |
| 286 | + |
| 287 | + keys = GrantKeys.parse([_KEY_B64]) |
| 288 | + token, _ = mint_grant_token(keys, principal="alice", scopes=[], purpose="p", ttl_seconds=60) |
| 289 | + with pytest.raises(ClientError): |
| 290 | + _whoami(_app(authenticate=_resolve_authenticate()), bearer=token) |
0 commit comments