vgi-python 0.40.0__py3-none-any.whl → 0.42.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
vgi/_duckdb.py CHANGED
@@ -7,8 +7,8 @@ engine (expression-filter evaluation, the transactor, the statistics
7
7
  fixtures) resolves it through this module, which prefers Query Farm's
8
8
  ``haybarn`` distribution when installed and falls back to stock ``duckdb``.
9
9
 
10
- Install one via the extras: ``pip install vgi[haybarn]`` (preferred) or
11
- ``pip install vgi[duckdb]``.
10
+ Install one via the extras: ``pip install 'vgi-python[haybarn]'`` (preferred) or
11
+ ``pip install 'vgi-python[duckdb]'``.
12
12
  """
13
13
 
14
14
  from __future__ import annotations
@@ -50,7 +50,7 @@ def engine_module() -> ModuleType:
50
50
  else:
51
51
  raise ImportError(
52
52
  "No DuckDB-compatible engine is installed. Install 'haybarn' (preferred) "
53
- "or 'duckdb', e.g. `pip install vgi[haybarn]` or `pip install vgi[duckdb]`."
53
+ "or 'duckdb', e.g. `pip install 'vgi-python[haybarn]'` or `pip install 'vgi-python[duckdb]'`."
54
54
  )
55
55
  return _engine
56
56
 
@@ -42,7 +42,9 @@ at least once (tables, a view, scalar / aggregate / table functions, scalar and
42
42
  table macros), split over two schemas (``main`` and ``extra``), so RPC
43
43
  accounting in ``vgi/test/sql/integration/catalog/catalog_contents*.test`` is
44
44
  deterministic. Served by ``vgi-fixture-worker`` and ``vgi-fixture-http``
45
- (MetaWorker), so the same tests run over every transport. Their functions
45
+ (MetaWorker), so the same tests run over every transport. Every other SDK's
46
+ fixture worker serves these six catalogs too, and the same ``.test`` files run
47
+ against each of them, so the semantics here are the cross-SDK contract. Their functions
46
48
  re-use the example catalog's classes (``main.double`` ...), so every worker
47
49
  here sets ``route_unattached_calls = False``: they are reachable only through
48
50
  their own attach, and an unattached ``main.double`` still routes to the
@@ -372,7 +372,7 @@ def main() -> None:
372
372
  from vgi._test_fixtures.catalog_contents import CONTENTS_WORKERS
373
373
  from vgi._test_fixtures.narrow_bind.worker import NarrowBindWorker
374
374
  from vgi._test_fixtures.twin_catalogs import TwinAWorker, TwinBWorker
375
- from vgi.rpc_server import build_rpc_server
375
+ from vgi.rpc_server import build_rpc_server, resolve_grant_keys
376
376
 
377
377
  worker_classes: list[type] = [
378
378
  ExampleWorker,
@@ -408,6 +408,7 @@ def main() -> None:
408
408
  describe=describe,
409
409
  external_location=external_location,
410
410
  introspect_principals=introspect_principals,
411
+ grant_keys=resolve_grant_keys(),
411
412
  )
412
413
  wsgi_app = make_wsgi_app(
413
414
  server,
@@ -61,12 +61,18 @@ class IdentityExampleWorker(ExampleWorker):
61
61
  def conformance_authenticate(req: Any) -> AuthContext:
62
62
  """Derive the caller from ``X-Conformance-Principal`` / ``X-Conformance-Auth-Time``.
63
63
 
64
- An absent principal header means *unauthenticated*. The auth-time header is
64
+ An absent principal header means *unauthenticated*, unless an
65
+ ``Authorization`` header is present: that request falls through to the
66
+ sealed-grant and ``resolve_token`` bearers. The auth-time header is
65
67
  placed in the claims verbatim and unparsed, because the guard does the
66
68
  parsing and the fixture only carries the value.
67
69
  """
68
70
  principal = req.get_header(PRINCIPAL_HEADER)
69
71
  if not principal:
72
+ if req.get_header("Authorization"):
73
+ # A bearer is not ours: fall through to the identity bearer
74
+ # authenticators vgi-rpc appends (sealed grants, resolve_token).
75
+ raise ValueError("no conformance principal header")
70
76
  return AuthContext(domain=None, authenticated=False, principal=None)
71
77
  claims: dict[str, object] = {}
72
78
  auth_time = req.get_header(AUTH_TIME_HEADER)
vgi/client/__init__.py CHANGED
@@ -45,7 +45,7 @@ from vgi.client.aggregate import (
45
45
  AggregateSession,
46
46
  AggregateStreamingSession,
47
47
  )
48
- from vgi.client.catalog_mixin import CatalogClientMixin, CatalogContents
48
+ from vgi.client.catalog_mixin import CatalogClientMixin, CatalogContents, CatalogSnapshot
49
49
  from vgi.client.client import Client, ClientError, ResumableTableScan, ResumeUnsupported
50
50
 
51
51
  if TYPE_CHECKING:
@@ -57,6 +57,7 @@ __all__ = [
57
57
  "AggregateStreamingSession",
58
58
  "CatalogClientMixin",
59
59
  "CatalogContents",
60
+ "CatalogSnapshot",
60
61
  "Client",
61
62
  "ClientError",
62
63
  "OutputWriter",
@@ -42,7 +42,7 @@ import shlex
42
42
  import sys
43
43
  from collections.abc import Iterator, Sequence
44
44
  from contextlib import contextmanager
45
- from dataclasses import dataclass
45
+ from dataclasses import dataclass, replace
46
46
  from typing import Any, Literal, overload
47
47
 
48
48
  import pyarrow as pa
@@ -129,6 +129,55 @@ class CatalogContents:
129
129
  schemas: list[SchemaContentsInfo]
130
130
 
131
131
 
132
+ @dataclass(frozen=True)
133
+ class CatalogSnapshot:
134
+ """A whole catalog — every schema and everything in it — from [`CatalogClientMixin.load_catalog`][].
135
+
136
+ Hold on to it and pass it back as ``previous`` to revalidate: when it came
137
+ from ``catalog_contents`` with an etag, the next load sends
138
+ ``if_none_match`` and a ``not_modified`` answer returns this same content
139
+ without re-downloading it.
140
+
141
+ Attributes:
142
+ schemas: One [`SchemaContentsInfo`][] per schema, parents first.
143
+ catalog_version: The version the snapshot was taken at, or ``None``
144
+ when it was assembled from the per-schema RPCs (which carry none).
145
+ etag: The ``catalog_contents`` validator, or ``None`` when the worker
146
+ does not revalidate or the snapshot did not come from
147
+ ``catalog_contents``.
148
+ source: ``"catalog_contents"`` (one RPC) or ``"per_schema"``
149
+ (``catalog_schemas`` plus the per-schema ``catalog_schema_contents_*``
150
+ RPCs — the worker did not advertise ``catalog_contents``, the load
151
+ ran inside a transaction, or ``catalog_contents`` failed).
152
+ not_modified: ``True`` when this is ``previous`` confirmed current by a
153
+ ``not_modified`` answer.
154
+ fallback_reason: Why ``catalog_contents`` was advertised but not used
155
+ (its error message, or a protocol violation), else ``None``.
156
+ """
157
+
158
+ schemas: list[SchemaContentsInfo]
159
+ catalog_version: int | None
160
+ etag: str | None
161
+ source: Literal["catalog_contents", "per_schema"]
162
+ not_modified: bool = False
163
+ fallback_reason: str | None = None
164
+
165
+
166
+ # ``SchemaInfo.estimated_object_count`` key for each per-schema kind; a count of
167
+ # exactly 0 is a hard "none of this kind" guarantee, so the RPC is skipped (the
168
+ # same rule the DuckDB extension and ``CatalogInterface.catalog_contents`` apply).
169
+ _PER_SCHEMA_KINDS: tuple[tuple[str, SchemaObjectType, str], ...] = (
170
+ ("tables", SchemaObjectType.TABLE, "table"),
171
+ ("views", SchemaObjectType.VIEW, "view"),
172
+ ("scalar_functions", SchemaObjectType.SCALAR_FUNCTION, "scalar_function"),
173
+ ("aggregate_functions", SchemaObjectType.AGGREGATE_FUNCTION, "aggregate_function"),
174
+ ("table_functions", SchemaObjectType.TABLE_FUNCTION, "table_function"),
175
+ ("scalar_macros", SchemaObjectType.SCALAR_MACRO, "macro"),
176
+ ("table_macros", SchemaObjectType.TABLE_MACRO, "macro"),
177
+ ("indexes", SchemaObjectType.INDEX, "index"),
178
+ )
179
+
180
+
132
181
  class CatalogClientError(Exception):
133
182
  """Error raised by catalog operations."""
134
183
 
@@ -492,13 +541,123 @@ class CatalogClientMixin:
492
541
  one [`SchemaContentsInfo`][] per schema, parents first.
493
542
 
494
543
  """
544
+ # Decode inside the connection block so a malformed item surfaces as
545
+ # CatalogClientError (which load_catalog falls back on), not a bare
546
+ # decode error.
495
547
  with self._catalog_connect() as proxy:
496
548
  response = proxy.catalog_contents(attach_opaque_data=attach_opaque_data, if_none_match=if_none_match)
497
- return CatalogContents(
498
- catalog_version=response.catalog_version,
499
- etag=response.etag,
500
- not_modified=response.not_modified,
501
- schemas=[_decode_schema_contents(entry) for entry in response.schemas],
549
+ return CatalogContents(
550
+ catalog_version=response.catalog_version,
551
+ etag=response.etag,
552
+ not_modified=response.not_modified,
553
+ schemas=[_decode_schema_contents(entry) for entry in response.schemas],
554
+ )
555
+
556
+ def load_catalog(
557
+ self,
558
+ *,
559
+ attach: CatalogAttachResult,
560
+ previous: CatalogSnapshot | None = None,
561
+ transaction_opaque_data: TransactionOpaqueData | None = None,
562
+ ) -> CatalogSnapshot:
563
+ """Load every schema and all of its contents, in as few RPCs as the worker allows.
564
+
565
+ This is the client's whole-catalog enumeration. When the attach result
566
+ advertises ``supports_catalog_contents`` it is one ``catalog_contents``
567
+ call; otherwise — or inside a transaction, or when that call fails — it
568
+ is ``catalog_schemas`` plus the per-schema ``catalog_schema_contents_*``
569
+ RPCs (skipping a kind whose ``estimated_object_count`` is exactly 0).
570
+ ``catalog_contents`` is never sent to a worker that did not advertise it.
571
+
572
+ Revalidation: pass the snapshot from a previous load as ``previous``.
573
+ If it came from ``catalog_contents`` with an etag, the call sends
574
+ ``if_none_match``; a ``not_modified`` answer returns ``previous``'s
575
+ content (with ``not_modified=True`` and the current version), and a full
576
+ answer replaces it. A snapshot older than ``previous`` (a lagging
577
+ replica) is retried once, then the per-schema RPCs are used.
578
+
579
+ Args:
580
+ attach: The result of [`catalog_attach`][vgi.client.CatalogClientMixin.catalog_attach]
581
+ for this catalog.
582
+ previous: A snapshot already held, to revalidate instead of reload.
583
+ transaction_opaque_data: Load inside this transaction. ``catalog_contents``
584
+ returns only the committed catalog, so a transactional load always
585
+ uses the (transaction-aware) per-schema RPCs.
586
+
587
+ Returns:
588
+ A [`CatalogSnapshot`][]. ``source`` says which path served it and
589
+ ``fallback_reason`` why an advertised ``catalog_contents`` was not used.
590
+ """
591
+ attach_opaque_data = attach.attach_opaque_data
592
+ if not attach.supports_catalog_contents or transaction_opaque_data is not None:
593
+ return self._load_catalog_per_schema(attach_opaque_data, transaction_opaque_data, None)
594
+
595
+ if_none_match = previous.etag if previous is not None and previous.source == "catalog_contents" else None
596
+ known_version = previous.catalog_version if previous is not None else None
597
+ reason: str
598
+ for _attempt in range(2):
599
+ try:
600
+ contents = self.contents(attach_opaque_data=attach_opaque_data, if_none_match=if_none_match)
601
+ except CatalogClientError as e:
602
+ reason = str(e)
603
+ break
604
+ if contents.not_modified:
605
+ if previous is None or if_none_match is None or contents.schemas:
606
+ reason = "catalog_contents answered not_modified to a request it could not match"
607
+ break
608
+ return replace(
609
+ previous,
610
+ catalog_version=contents.catalog_version,
611
+ etag=contents.etag if contents.etag is not None else previous.etag,
612
+ not_modified=True,
613
+ )
614
+ # Versions are monotonic within a session; 0 means "unknown".
615
+ if known_version and contents.catalog_version and contents.catalog_version < known_version:
616
+ reason = (
617
+ f"catalog_contents returned version {contents.catalog_version}, "
618
+ f"older than the known version {known_version}"
619
+ )
620
+ continue
621
+ return CatalogSnapshot(
622
+ schemas=contents.schemas,
623
+ catalog_version=contents.catalog_version,
624
+ etag=contents.etag,
625
+ source="catalog_contents",
626
+ )
627
+ return self._load_catalog_per_schema(attach_opaque_data, None, reason)
628
+
629
+ def _load_catalog_per_schema(
630
+ self,
631
+ attach_opaque_data: AttachOpaqueData,
632
+ transaction_opaque_data: TransactionOpaqueData | None,
633
+ fallback_reason: str | None,
634
+ ) -> CatalogSnapshot:
635
+ """Assemble a snapshot from ``catalog_schemas`` and the per-schema RPCs."""
636
+ result: list[SchemaContentsInfo] = []
637
+ for schema in self.schemas(
638
+ attach_opaque_data=attach_opaque_data, transaction_opaque_data=transaction_opaque_data
639
+ ):
640
+ counts = schema.estimated_object_count or {}
641
+ kinds: dict[str, list[Any]] = {}
642
+ for attr, object_type, count_key in _PER_SCHEMA_KINDS:
643
+ if counts.get(count_key, 1) == 0:
644
+ kinds[attr] = []
645
+ continue
646
+ kinds[attr] = list(
647
+ self.schema_contents(
648
+ attach_opaque_data=attach_opaque_data,
649
+ transaction_opaque_data=transaction_opaque_data,
650
+ path=list(schema.path),
651
+ type=object_type,
652
+ )
653
+ )
654
+ result.append(SchemaContentsInfo(schema=schema, **kinds))
655
+ return CatalogSnapshot(
656
+ schemas=result,
657
+ catalog_version=None,
658
+ etag=None,
659
+ source="per_schema",
660
+ fallback_reason=fallback_reason,
502
661
  )
503
662
 
504
663
  def schema_get(
vgi/client/cli_catalog.py CHANGED
@@ -11,6 +11,7 @@ catalog
11
11
  ├── create <name> # Create a new catalog
12
12
  ├── drop <name> # Drop a catalog
13
13
  ├── version # Get catalog version (--attach-opaque-data or --catalog)
14
+ ├── contents # Every schema and its contents (catalog_contents when advertised)
14
15
  ├── schema # Schema operations
15
16
  │ ├── list/get/create/drop/contents
16
17
  ├── table # Table operations
@@ -163,6 +164,52 @@ def catalog_version(
163
164
  output_json({"version": version, "attach_opaque_data": bytes_to_hex(resolved_attach_opaque_data)})
164
165
 
165
166
 
167
+ @catalog.command("contents")
168
+ @click.option("--catalog", "catalog_name", required=True, help="Catalog name to attach")
169
+ @click.option("--attach-options", default="{}", help="Attach options as JSON")
170
+ @click.option("--worker", "-w", required=True, help="VGI worker command")
171
+ def catalog_contents(catalog_name: str, attach_options: str, worker: str) -> None:
172
+ """List every schema and the names of everything in it.
173
+
174
+ Uses one ``catalog_contents`` call when the catalog advertises it, else
175
+ ``catalog_schemas`` plus the per-schema RPCs (see ``Client.load_catalog``).
176
+ """
177
+ client = Client(worker)
178
+ attach = client.catalog_attach(
179
+ name=catalog_name,
180
+ options=parse_json_option(attach_options, "--attach-options"),
181
+ data_version_spec=None,
182
+ implementation_version=None,
183
+ )
184
+ snapshot = client.load_catalog(attach=attach)
185
+ kinds = (
186
+ "tables",
187
+ "views",
188
+ "scalar_functions",
189
+ "aggregate_functions",
190
+ "table_functions",
191
+ "scalar_macros",
192
+ "table_macros",
193
+ "indexes",
194
+ )
195
+ output_json(
196
+ {
197
+ "source": snapshot.source,
198
+ "catalog_version": snapshot.catalog_version,
199
+ "etag": snapshot.etag,
200
+ "fallback_reason": snapshot.fallback_reason,
201
+ "schemas": [
202
+ {
203
+ "path": list(entry.schema.path),
204
+ "comment": entry.schema.comment,
205
+ **{kind: [item.name for item in getattr(entry, kind)] for kind in kinds},
206
+ }
207
+ for entry in snapshot.schemas
208
+ ],
209
+ }
210
+ )
211
+
212
+
166
213
  # Add nested subcommand groups
167
214
  catalog.add_command(schema)
168
215
  catalog.add_command(table)
vgi/client/cli_utils.py CHANGED
@@ -389,6 +389,7 @@ def catalog_attach_result_to_dict(result: CatalogAttachResult) -> dict[str, Any]
389
389
  "catalog_version": result.catalog_version,
390
390
  "attach_opaque_data_required": result.attach_opaque_data_required,
391
391
  "default_schema": result.default_schema,
392
+ "supports_catalog_contents": result.supports_catalog_contents,
392
393
  "settings": [bytes_to_hex(s) for s in result.settings],
393
394
  "resolved_data_version": result.resolved_data_version,
394
395
  "resolved_implementation_version": result.resolved_implementation_version,
vgi/client/client.py CHANGED
@@ -54,6 +54,7 @@ Key methods
54
54
  client.catalog_attach(...) — open a catalog
55
55
  client.schemas(...) — list schemas
56
56
  client.schema_contents(...) — list tables/views/functions/macros
57
+ client.load_catalog(...) — whole catalog (catalog_contents when advertised)
57
58
  client.scalar_function(...) — invoke a scalar
58
59
  client.table_function(...) — invoke a table function
59
60
  client.table_function_plan(...) — plan a table function into named, redeemable splits
vgi/rpc_server.py CHANGED
@@ -34,13 +34,14 @@ from typing import TYPE_CHECKING, Any, Literal
34
34
 
35
35
  if TYPE_CHECKING:
36
36
  from vgi_rpc.external import ExternalLocationConfig
37
+ from vgi_rpc.grants import GrantKeys
37
38
  from vgi_rpc.rpc import RpcServer
38
39
  from vgi_rpc.rpc._token_identity import IdentityImpl
39
40
 
40
41
  from vgi.meta_worker import MetaWorker
41
42
  from vgi.worker import Worker
42
43
 
43
- __all__ = ["Transport", "build_rpc_server"]
44
+ __all__ = ["Transport", "build_rpc_server", "resolve_grant_keys"]
44
45
 
45
46
  Transport = Literal["pipe", "unix", "tcp", "iroh", "http"]
46
47
  """The transport a server is being built for.
@@ -65,6 +66,7 @@ def build_rpc_server(
65
66
  transport: Transport,
66
67
  describe: bool = True,
67
68
  introspect_principals: Iterable[str] | None = None,
69
+ grant_keys: GrantKeys | None = None,
68
70
  external_location: ExternalLocationConfig | None = None,
69
71
  otel_config: Any = None,
70
72
  ) -> RpcServer:
@@ -83,6 +85,11 @@ def build_rpc_server(
83
85
  introspect_principals: HTTP only. Principals permitted to call
84
86
  ``introspect_token``; ``None`` reads ``VGI_INTROSPECT_PRINCIPALS``.
85
87
  Only consulted when the worker overrides ``resolve_token``.
88
+ grant_keys: HTTP only. Sealed-grant configuration (see
89
+ [`resolve_grant_keys`][vgi.rpc_server.resolve_grant_keys]). When
90
+ given, Identity is hosted even without hooks: the framework mints
91
+ sealed grants unless the worker overrides ``mint_grant``, and the
92
+ HTTP server accepts them back as bearer credentials.
86
93
  external_location: Optional external-storage configuration for large
87
94
  batches (HTTP).
88
95
  otel_config: Optional ``OtelConfig``. When given, the server is
@@ -116,13 +123,18 @@ def build_rpc_server(
116
123
  if transport in _IDENTITY_TRANSPORTS:
117
124
  # A Worker's hooks are classmethods; a MetaWorker answers for the one
118
125
  # child that implements each hook.
119
- identity = _build_identity(type(worker) if isinstance(worker, Worker) else worker, introspect_principals)
126
+ identity = _build_identity(
127
+ type(worker) if isinstance(worker, Worker) else worker, introspect_principals, grant_keys
128
+ )
120
129
 
121
130
  server = RpcServer(
122
131
  primary,
123
132
  worker,
124
133
  extra_protocols=extra,
125
134
  identity=identity,
135
+ # Explicit, never vgi-rpc's "env" default: grants are an HTTP-only
136
+ # identity feature here, and the caller resolved them already.
137
+ grant_keys=grant_keys if transport in _IDENTITY_TRANSPORTS else None,
126
138
  external_location=external_location,
127
139
  enable_describe=describe,
128
140
  server_version=_get_vgi_version(),
@@ -191,6 +203,7 @@ def _validated_hosted_protocols(worker: Worker | MetaWorker, primary: type) -> t
191
203
  def _build_identity(
192
204
  worker_cls: type[Worker] | MetaWorker,
193
205
  introspect_principals: Iterable[str] | None,
206
+ grant_keys: GrantKeys | None = None,
194
207
  ) -> IdentityImpl | None:
195
208
  """Build the ``vgi_rpc.Identity.v1`` implementation, or ``None``.
196
209
 
@@ -217,6 +230,9 @@ def _build_identity(
217
230
  the one child class that overrides each.
218
231
  introspect_principals: Principals permitted to introspect, or ``None``
219
232
  to read the environment.
233
+ grant_keys: Sealed-grant configuration. When given, Identity is built
234
+ even with no hook, and mints sealed grants unless the worker
235
+ overrides ``mint_grant``.
220
236
 
221
237
  Returns:
222
238
  The implementation, or ``None`` when this worker does not resolve
@@ -225,7 +241,7 @@ def _build_identity(
225
241
  """
226
242
  resolver = worker_cls._introspect_resolver()
227
243
  minter = worker_cls._grant_minter()
228
- if resolver is None and minter is None:
244
+ if resolver is None and minter is None and grant_keys is None:
229
245
  return None
230
246
 
231
247
  # Not re-exported by ``vgi_rpc.rpc``, so the private module is the only
@@ -239,6 +255,9 @@ def _build_identity(
239
255
  # validates it only when a resolver is supplied — a worker that mints
240
256
  # grants but resolves nothing is not an oracle and needs no allowlist.
241
257
  introspect_principals=(_resolve_introspect_principals(introspect_principals) if resolver is not None else None),
258
+ # With keys and no mint_grant override, IdentityImpl mints sealed
259
+ # grants itself; the HTTP server then accepts them as bearers.
260
+ grant_keys=grant_keys,
242
261
  )
243
262
 
244
263
 
@@ -285,3 +304,37 @@ def _resolve_introspect_principals(explicit: Iterable[str] | None) -> list[str]:
285
304
  )
286
305
  sys.exit(1)
287
306
  return principals
307
+
308
+
309
+ def resolve_grant_keys(explicit: Sequence[str] | None = None) -> GrantKeys | None:
310
+ """Resolve the sealed-grant configuration, or exit with an actionable message.
311
+
312
+ Grants are opt-in: with no key configured this returns ``None`` and
313
+ nothing changes. Keys come from *explicit* (``--grant-key``, repeatable)
314
+ or else ``VGI_RPC_GRANT_KEYS`` (comma-separated). Either way
315
+ ``VGI_RPC_GRANT_AUDIENCE`` and ``VGI_RPC_GRANT_MAX_TTL_SECONDS`` (default
316
+ 7 days) apply. Each key is standard base64 of exactly 32 bytes; the first
317
+ mints and every key verifies, which is how keys rotate.
318
+
319
+ Args:
320
+ explicit: Keys given on the command line, or ``None`` to read the
321
+ environment.
322
+
323
+ Returns:
324
+ The configuration, or ``None`` when no key is configured.
325
+
326
+ Raises:
327
+ SystemExit: A key or lifetime is malformed. A worker refuses to start
328
+ rather than run with a key it misread.
329
+
330
+ """
331
+ from vgi_rpc.grants import GRANT_KEYS_ENV, GrantKeys
332
+
333
+ env: dict[str, str] = dict(os.environ)
334
+ if explicit:
335
+ env[GRANT_KEYS_ENV] = ",".join(explicit)
336
+ try:
337
+ return GrantKeys.from_env(env)
338
+ except ValueError as exc:
339
+ sys.stderr.write(f"Error: invalid sealed-grant configuration ({GRANT_KEYS_ENV} / --grant-key): {exc}\n")
340
+ sys.exit(1)
vgi/serve.py CHANGED
@@ -41,6 +41,7 @@ from vgi.profiling import maybe_start_profile
41
41
 
42
42
  if TYPE_CHECKING:
43
43
  import falcon
44
+ from vgi_rpc.grants import GrantKeys
44
45
  from vgi_rpc.otel import OtelConfig
45
46
  from vgi_rpc.rpc import AuthContext, PeerAuthenticationPolicy, PeerIdentityProvider
46
47
 
@@ -216,6 +217,7 @@ def create_app(
216
217
  max_stream_response_bytes: int | None = None,
217
218
  max_externalized_response_bytes: int | None = None,
218
219
  introspect_principals: Iterable[str] | None = None,
220
+ grant_keys: GrantKeys | None = None,
219
221
  iroh_bridge_issuer: str | None = None,
220
222
  iroh_trusted_proxy_addresses: Iterable[str] = (),
221
223
  iroh_authenticate: bool = True,
@@ -277,6 +279,16 @@ def create_app(
277
279
  ``vgi_rpc.Identity.v1``'s ``introspect_token``. Only consulted
278
280
  when the worker class overrides ``resolve_token``. ``None`` reads
279
281
  ``VGI_INTROSPECT_PRINCIPALS``.
282
+ grant_keys: Sealed-grant configuration. ``None`` reads
283
+ ``VGI_RPC_GRANT_KEYS`` (see
284
+ [`resolve_grant_keys`][vgi.rpc_server.resolve_grant_keys]); no key
285
+ configured means grants are off. With keys, ``issue_grant`` mints
286
+ sealed grants (unless the worker overrides ``mint_grant``) and
287
+ requests may present them as ``Authorization: Bearer <grant>``.
288
+ Independently, a worker that overrides ``resolve_token`` has it
289
+ consulted for bearer credentials. Both come after *authenticate*
290
+ in the chain, so the deployment's own authenticator answers first
291
+ and must raise ``ValueError`` for a credential it does not know.
280
292
  iroh_bridge_issuer: Operator-controlled identity namespace for an
281
293
  ``iroh-http/2`` bridge. ``None`` disables Iroh header trust.
282
294
  iroh_trusted_proxy_addresses: Exact immediate bridge addresses.
@@ -297,7 +309,7 @@ def create_app(
297
309
  sys.exit(1)
298
310
 
299
311
  from vgi.otel import VgiTracer
300
- from vgi.rpc_server import build_rpc_server
312
+ from vgi.rpc_server import build_rpc_server, resolve_grant_keys
301
313
 
302
314
  # Resolve the signing key once, here, so the worker (which seals catalog
303
315
  # opaque-data envelopes) and the HTTP state-token machinery share the same
@@ -319,7 +331,9 @@ def create_app(
319
331
  transport="http",
320
332
  describe=describe,
321
333
  introspect_principals=introspect_principals,
334
+ grant_keys=grant_keys if grant_keys is not None else resolve_grant_keys(),
322
335
  )
336
+ authenticate, identity_bearer = _compose_identity_bearer(server, authenticate)
323
337
 
324
338
  effective_peer_identity_providers = tuple(peer_identity_providers)
325
339
  effective_peer_authentication_policy = peer_authentication_policy
@@ -356,6 +370,7 @@ def create_app(
356
370
  max_stream_response_bytes=max_stream_response_bytes,
357
371
  max_externalized_response_bytes=max_externalized_response_bytes,
358
372
  enable_landing_page=False,
373
+ identity_bearer=identity_bearer,
359
374
  )
360
375
 
361
376
  # Frontend: either redirect to external CDN or serve pre-rendered worker page
@@ -490,6 +505,16 @@ def main() -> None:
490
505
  "that case, with no permissive default. Env: VGI_INTROSPECT_PRINCIPALS."
491
506
  ),
492
507
  ),
508
+ grant_key: list[str] | None = typer.Option( # noqa: B008
509
+ None,
510
+ "--grant-key",
511
+ help=(
512
+ "Sealed-grant key: standard base64 of 32 bytes; repeat to rotate (the first "
513
+ "mints, all verify). Enables issue_grant without a mint_grant override and "
514
+ "accepts the grants as HTTP bearer credentials. Env: VGI_RPC_GRANT_KEYS "
515
+ "(comma-separated), VGI_RPC_GRANT_AUDIENCE, VGI_RPC_GRANT_MAX_TTL_SECONDS."
516
+ ),
517
+ ),
493
518
  access_log_sample: float | None = typer.Option( # noqa: B008
494
519
  None,
495
520
  "--access-log-sample",
@@ -573,6 +598,7 @@ def main() -> None:
573
598
  max_stream_response_bytes=max_stream_response_bytes,
574
599
  max_externalized_response_bytes=max_externalized_response_bytes,
575
600
  introspect_principals=introspect_principals,
601
+ grant_keys=grant_key,
576
602
  server=server,
577
603
  worker_ref=worker_ref,
578
604
  http_workers=http_workers,
@@ -770,7 +796,44 @@ def _resolve_authenticate() -> Callable[..., Any] | None:
770
796
  # this worker", with user identity handled upstream.
771
797
  from vgi_rpc.http import require_all
772
798
 
773
- return require_all(gate, inner)
799
+ gated = require_all(gate, inner)
800
+ # Kept so create_app can put the identity bearers (sealed grants,
801
+ # resolve_token) inside the gate rather than beside it.
802
+ setattr(gated, _PROXY_GATE_PARTS_ATTR, (gate, inner))
803
+ return gated
804
+
805
+
806
+ #: Attribute on a proxy-gated authenticator holding its ``(gate, inner)``.
807
+ _PROXY_GATE_PARTS_ATTR = "_vgi_proxy_gate_parts"
808
+
809
+
810
+ def _compose_identity_bearer(
811
+ server: Any,
812
+ authenticate: Callable[..., Any] | None,
813
+ ) -> tuple[Callable[..., Any] | None, bool]:
814
+ """Return ``(authenticate, identity_bearer)`` for ``make_wsgi_app``.
815
+
816
+ vgi-rpc appends the identity bearers (sealed grants, then the worker's
817
+ ``resolve_token``) after *authenticate* itself. It refuses when
818
+ *authenticate* depends on proxy-injected evidence, because an OR beside a
819
+ proxy-proof gate would let a grant bypass it. VGI's own gate
820
+ (``VGI_PROXY_PROOF_MODE``) is built by :func:`_resolve_authenticate`, so
821
+ here the bearers go *inside* it: ``require_all(gate, inner, then grants,
822
+ then resolve_token)``. Any other authenticator is left to vgi-rpc.
823
+ """
824
+ identity = getattr(server, "identity", None)
825
+ parts = getattr(authenticate, _PROXY_GATE_PARTS_ATTR, None)
826
+ if identity is None or parts is None:
827
+ return authenticate, True
828
+ if identity.grant_keys is None and identity.resolve_token_hook is None:
829
+ return authenticate, True
830
+ from vgi_rpc.http import compose_identity_authenticate, require_all
831
+
832
+ gate, inner = parts
833
+ composed = compose_identity_authenticate(
834
+ inner, grant_keys=identity.grant_keys, resolve_token=identity.resolve_token_hook
835
+ )
836
+ return require_all(gate, composed), False
774
837
 
775
838
 
776
839
  def _resolve_proxy_proof_gate() -> Any | None:
@@ -1352,6 +1415,7 @@ def _serve_http(
1352
1415
  max_stream_response_bytes: int | None = None,
1353
1416
  max_externalized_response_bytes: int | None = None,
1354
1417
  introspect_principals: str | None = None,
1418
+ grant_keys: Sequence[str] | None = None,
1355
1419
  server: str = "waitress",
1356
1420
  worker_ref: str | None = None,
1357
1421
  http_workers: int | None = None,
@@ -1369,6 +1433,13 @@ def _serve_http(
1369
1433
  # export_serve_config for why the environment is the channel.
1370
1434
  if introspect_principals is not None:
1371
1435
  os.environ["VGI_INTROSPECT_PRINCIPALS"] = introspect_principals
1436
+ if grant_keys:
1437
+ from vgi_rpc.grants import GRANT_KEYS_ENV
1438
+
1439
+ from vgi.rpc_server import resolve_grant_keys
1440
+
1441
+ resolve_grant_keys(grant_keys) # a malformed key stops startup here
1442
+ os.environ[GRANT_KEYS_ENV] = ",".join(grant_keys)
1372
1443
 
1373
1444
  if server == "granian":
1374
1445
  _serve_http_granian(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: vgi-python
3
- Version: 0.40.0
3
+ Version: 0.42.0
4
4
  Summary: Vector Gateway Interface - Connect DuckDB to external programs via Apache Arrow
5
5
  Project-URL: Homepage, https://query.farm
6
6
  Project-URL: Repository, https://github.com/Query-farm/vgi-python
@@ -162,7 +162,7 @@ Requires-Dist: httpx2>=2.12.0
162
162
  Requires-Dist: platformdirs
163
163
  Requires-Dist: pyarrow
164
164
  Requires-Dist: typer>=0.9
165
- Requires-Dist: vgi-rpc>=0.48.0
165
+ Requires-Dist: vgi-rpc>=0.49.0
166
166
  Provides-Extra: azure
167
167
  Requires-Dist: azure-identity>=1.16.0; extra == 'azure'
168
168
  Requires-Dist: pymssql>=2.3.0; extra == 'azure'
@@ -1,5 +1,5 @@
1
1
  vgi/__init__.py,sha256=8uLMNQcNzWjp8VzYtj2u98_Wp8WpB8c_dKlMv67TH0g,5384
2
- vgi/_duckdb.py,sha256=YB5D7N3Bwg_xP6X8a5QlumtlAovSej1A1Go5XlNGVko,2162
2
+ vgi/_duckdb.py,sha256=Tu8Yb4vABRv4yOMo7qul8LxG_wH5ZqZuOCSSjyiMgbY,2198
3
3
  vgi/_storage_profile.py,sha256=VkTsXojuE0tHEzurmteQSAiL1vI3CZSgYkL6D_h8GvE,5061
4
4
  vgi/aggregate_function.py,sha256=NYxudbvQ9ZLkP2w5hRdd4YgsGUZ_0cVfqqXnlmJPfls,27389
5
5
  vgi/argument_spec.py,sha256=SADH97VlFu3asI8CWZ_I7XTuSxwqFciFgdupdAf-tkY,34231
@@ -25,13 +25,13 @@ vgi/profiling.py,sha256=rK7vij3oHhXC3JHI2BsMySsXh7_hrXi0aXpCW8iFSIc,3671
25
25
  vgi/protocol.py,sha256=J9vaxstGvxe4Vmlf_Aij9HkabGbdFx_nEsQI_m-V7ak,175438
26
26
  vgi/protocol_version.txt,sha256=yJuHwmLp1f_SDnOqx0oXcq3Mn1ohaCzDOtz1CG5f71w,6
27
27
  vgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
- vgi/rpc_server.py,sha256=KLFgOxnpFWRMkj9nR7HnrMBkqn1ahFuIOqgwd6RJ27o,12444
28
+ vgi/rpc_server.py,sha256=Qdqi6rHkX78SYXUZqG5D-a9NR0ZtGoxW22he0J9xS-0,14914
29
29
  vgi/scalar_function.py,sha256=YBy5s4eEBYTMjACGPE-8-82BObyJoM9qt9cnm24b_RQ,53485
30
30
  vgi/schema_path.py,sha256=GVlkZlCUj7GbVhgiocSMC6Ff5iTJhKqiYE62hMIEKiI,2403
31
31
  vgi/schema_utils.py,sha256=zQCliUgIpO2u77Mhtgg3OFvOEDNgJopu6kj2fw8YP1I,7945
32
32
  vgi/secret_protocol.py,sha256=iCDl7nF7Nb4cJV4vpfbldunTDqDJWzCzS7uCmOACf7U,7151
33
33
  vgi/secret_service.py,sha256=r7qCzfnOMirf9wSCUe-mgFe3z6v9_cDRzixuSAZG_bs,8973
34
- vgi/serve.py,sha256=UATWrggJe0TrcjEguC0P-vNN4UOMceEOqJl_3B2qV2Q,59899
34
+ vgi/serve.py,sha256=V56NcO9VzWaMlkUCXumLBsfMEZ8oZeckT_EJ9sumsQc,63442
35
35
  vgi/split_token.py,sha256=tutcksj-hgWrLY7UW3sSqkyoaD-cMqNTOeff40MGl_g,10850
36
36
  vgi/table_buffering_function.py,sha256=SI6yLAcs8WBHqS6TeaZ92s-Q6GXn0ReROyQWDujw2iA,19554
37
37
  vgi/table_filter_pushdown.py,sha256=4Qo4OAOLjTYQ5pinNTOjLCDnb96OHqpcxJlbSJo3cJU,67144
@@ -45,13 +45,13 @@ vgi/_test_fixtures/bad_enum.py,sha256=b8l-afmXgGR6iqgIUiVeqHu0C0F8Of8ahEmdQGtqd1
45
45
  vgi/_test_fixtures/bad_protocol.py,sha256=fPU7rpXEgBxRtPHfuCa0O2AHNRRbZDllLfydHsAKPEg,2992
46
46
  vgi/_test_fixtures/cancellable.py,sha256=hetTRLyT4kkazPNcxAdfzj0pYzEmegMksamJbR0w3Ho,12151
47
47
  vgi/_test_fixtures/catalog.py,sha256=zjBKBgg4TKoDQ9SYIFj7sx6bOL3_aXHMJHSzTgk1fdA,28526
48
- vgi/_test_fixtures/catalog_contents.py,sha256=Hvt98vLMhdWwk9FvdtPx5GoZJ1ZLgiCFBA2NLEJohVY,11765
48
+ vgi/_test_fixtures/catalog_contents.py,sha256=C9vxHil2kmsqoELiTq2jZINBOwONJ2KIIYrreNMEN6c,11934
49
49
  vgi/_test_fixtures/companion.py,sha256=Ya_rg2J4tXOYC7zBFOjTTh-4AYuRhJN5BT4tVZULQ-Q,6037
50
50
  vgi/_test_fixtures/copy_from.py,sha256=YAWametIhrKE_K_kRwx37QpKMY0yBWAordE5-cNVn4M,6134
51
51
  vgi/_test_fixtures/copy_to.py,sha256=fFHxPYVd0wfAfMEvpeBRijqUasBeoAGREY3kHmm_oK8,9667
52
52
  vgi/_test_fixtures/global_functions.py,sha256=G1h0w5o6bHMULDFLZuNjrZVrBbO6Cre6kTqTnQwTFmE,11251
53
- vgi/_test_fixtures/http_server.py,sha256=M0drWRwy-apDmQcXfTTAnkAoLRpL9gG5O8l0UwhVe1Q,20487
54
- vgi/_test_fixtures/identity_fixture.py,sha256=9JvCzLEm6MNU_V9IJ_HREAbaR_wKy2oFaqfRj3n_QQ8,2928
53
+ vgi/_test_fixtures/http_server.py,sha256=hS6yKEE6I0UzwfIk_q7ORKee4jn4vD2xSCYItj8VkWE,20552
54
+ vgi/_test_fixtures/identity_fixture.py,sha256=wNTON3wpOGwH7HS9oCPb4Iav7GMYW0NlhtlZF1TL7aY,3318
55
55
  vgi/_test_fixtures/nest_tensor.py,sha256=UjjW9UwipYG3ehQpZOm4hdzsafMyvVc0ZKJCrqiZTPU,24471
56
56
  vgi/_test_fixtures/orchard_catalog.py,sha256=8Uv-mKTIgl-OYU9JLgG23owlHGmgniyF2chPoKXGtuI,1643
57
57
  vgi/_test_fixtures/secret_cache.py,sha256=su4a90I-YHTfsmVPoJGyycTkEzT7GItreYZEfbXMp2o,9552
@@ -133,17 +133,17 @@ vgi/catalog/duckdb_statistics.py,sha256=Bzw4UGO-zdKrontaCcRV2palO8G3XLCWh-AhZozv
133
133
  vgi/catalog/secret_type.py,sha256=1DoEMyQun9K5z53817rUuhG1YpHSEAsazEmubkRLVL0,3745
134
134
  vgi/catalog/setting.py,sha256=06QfgaAR-0BKflIJ0du6PGqA5BPMdrkwr6u7f4nddII,1846
135
135
  vgi/catalog/storage.py,sha256=DhngzywbhQUfLF7NjFyiPp4Sw2ovOhcUXAuW3rkBQD8,11935
136
- vgi/client/__init__.py,sha256=SXS_95QN2c68ssxEWwSvS7yfyq_2ORtDpf5YqSEwC0w,2507
136
+ vgi/client/__init__.py,sha256=QOZGVkJWOfB8r4jjiwfU2q8Q97eWkLTnT6BK1jPZ2RE,2547
137
137
  vgi/client/aggregate.py,sha256=EAaI-4d1mnNjGORZvLf3wfVuu8RXBTFGVbMek0DeGDg,38482
138
- vgi/client/catalog_mixin.py,sha256=ft6S0vfhvG77qvXixF-7g5jjfDE9ClwL0TM2h0mvv_w,61691
138
+ vgi/client/catalog_mixin.py,sha256=CMUl4AUDCFDerRLc6mieZau1JCx9CIAbG_hIRHsZRJs,69620
139
139
  vgi/client/cli.py,sha256=nahuRCjday0z74Gk9zxOxIOJX9n6fHrP7-CGwJoyTzU,22754
140
- vgi/client/cli_catalog.py,sha256=twjlhHGl7n2mNN4jc7DnDRQr-Zj9KLAF2eATIQnITHQ,5577
140
+ vgi/client/cli_catalog.py,sha256=RVhSJLFKSBfVwLfEb_GLT8vnuliOwU6FzlsVUF7Rpsw,7308
141
141
  vgi/client/cli_schema.py,sha256=9G2vZiaAyqINItcDCoARdM04qNlDyJ3IXw3SuA6jgU8,7818
142
142
  vgi/client/cli_table.py,sha256=H1xrKZF5yYmSe55YSrLWXGIXPmuGuzQcncEBvqL43zs,26498
143
143
  vgi/client/cli_transaction.py,sha256=TDW0ZrBy8XM_eYSkYk5ewEsnozfzDJOlopd66Fm7OOE,3142
144
- vgi/client/cli_utils.py,sha256=_JoNiVu12NV4Pg2b1wtWFt0L0uIWJ9p9mBIfIu-QIuM,17387
144
+ vgi/client/cli_utils.py,sha256=ykh7XK-yzcW6WGED2_DhPLIVPSyVG3KUdllV5ztBicw,17458
145
145
  vgi/client/cli_view.py,sha256=0qhxoS91-zFMjq8V_Fb0PNW-YDxERu8-zPpxSFEE-G8,8490
146
- vgi/client/client.py,sha256=cvBQHzM2Z2lamC-fkBAvZD4HuRxB03aUkEKFVo_E9GU,167312
146
+ vgi/client/client.py,sha256=GSkcVXQzI0VCmeUf7WY4kR0ZqFtYmeb6EkenarBvX5c,167399
147
147
  vgi/client/errors.py,sha256=cSN65cyQMk3crwZCz1IdPErK3mtlEkTNpu-nuQUDOQE,1633
148
148
  vgi/http/__init__.py,sha256=hlOwOVcoZqmEKVGk7ganOdr5ryRSpEhLBF9sRD7BkYc,608
149
149
  vgi/http/demo_storage.py,sha256=830s-H61thozC3eEuqdnwCpYFfvoJRq9vjyuXa5OURo,8769
@@ -156,8 +156,8 @@ vgi/transactor/_duckdb_compat.py,sha256=sXVZ9JLKAQyGR1BjWczSwdQEavtr-TcZPoVZZnTr
156
156
  vgi/transactor/client.py,sha256=7DTeMksogsw6ANjQjGOPpKYrV76rg4_kGjktMJf54jg,4486
157
157
  vgi/transactor/protocol.py,sha256=v59IvrKnuvwOXvn_HEcBAbCzDVcx0akgKN0R1mChXg0,5034
158
158
  vgi/transactor/server.py,sha256=nzsZQxJZdgappdYX8okrFIrjFtUbdM_Hhskl1fZ2nDY,32553
159
- vgi_python-0.40.0.dist-info/METADATA,sha256=lw7UWSpVsGOISgdZ3GYe4Y0cQoL59BlTUsqiXlstBng,25953
160
- vgi_python-0.40.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
161
- vgi_python-0.40.0.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
162
- vgi_python-0.40.0.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
163
- vgi_python-0.40.0.dist-info/RECORD,,
159
+ vgi_python-0.42.0.dist-info/METADATA,sha256=1c3-T4txCgcbPeUyxS-fOuMogZtSRPvbjNDOeau4pSg,25953
160
+ vgi_python-0.42.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
161
+ vgi_python-0.42.0.dist-info/entry_points.txt,sha256=3Kz1vgodw3pOL_xjtSyDB55-ZRy-U2X-X_Bdr582x0Q,165
162
+ vgi_python-0.42.0.dist-info/licenses/LICENSE,sha256=pbJb4zZasP6n5ifEV81wFu017TarjydaYVmGbHcehtY,6103
163
+ vgi_python-0.42.0.dist-info/RECORD,,