Skip to content

Commit ef53d91

Browse files
rustyconoverclaude
andcommitted
attach_tickets 2.0.0: AttachTicket.expires_at is a nullable timestamp
AttachTicket.expires_at was a float64 of unix seconds with +inf meaning "no maximum lifetime". It is now a nullable timestamp[us, tz="UTC"] (datetime | None in Python), null meaning no expiry, and AttachTickets.protocol_version moves 1.0.0 -> 2.0.0 so the exact major.minor check rejects a mismatched peer with a version error. The sealed ticket payload (expires_at int64 seconds, 0 = none) and its vectors are unchanged. A naive datetime is refused at construction. vgi-rpc renamed vgi_rpc.Identity.v1 -> vgi_rpc.Identity.v2 when IssuedGrant.expires_at became a non-null timestamp[us, tz="UTC"]; the docs, fixtures and tests here follow (mint_grant returns a tz-aware datetime). Requires the vgi-rpc release carrying Identity.v2; the dependency floor is bumped when that release exists. vgi/http/vgi-client.js is a bundle of the TypeScript SDK and still embeds Identity.v1 until it is rebuilt from a vgi-rpc-typescript release. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e11f28a commit ef53d91

19 files changed

Lines changed: 100 additions & 54 deletions

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ jobs:
129129
hosted-protocols:
130130
# vgi-rpc's cross-SDK hosted-protocols group: the fixture workers must host
131131
# vgi.v2 then conformance.Secondary.v1 on stdio, unix and HTTP, and pass the
132-
# Identity.v1 groups over HTTP (vgi-fixture-http --identity).
132+
# Identity.v2 groups over HTTP (vgi-fixture-http --identity).
133133
name: Hosted-protocols conformance
134134
runs-on: ubuntu-latest
135135
steps:

‎CLAUDE.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ vgi-client --input data.parquet --function sum_all_columns --worker vgi-fixture-
302302
| `VGI_OAUTH_DEVICE_CODE_CLIENT_SECRET` | Client secret for device-code flow (optional, URL-safe chars only) |
303303
| `VGI_OAUTH_USE_ID_TOKEN` | When `1`/`true`/`yes`, clients use OIDC `id_token` as Bearer instead of `access_token` |
304304
| `VGI_RPC_GRANT_KEYS` | Comma-separated base64 32-byte sealed-grant keys (first mints, all verify); enables grants as HTTP bearers. Also `VGI_RPC_GRANT_AUDIENCE`, `VGI_RPC_GRANT_MAX_TTL_SECONDS` |
305-
| `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) |
305+
| `VGI_INTROSPECT_PRINCIPALS` | Comma-separated principals permitted to call `vgi_rpc.Identity.v2`'s `introspect_token`. Required — no permissive default — whenever the worker implements `resolve_token()` (see below) |
306306
| `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 |
307307
| `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 |
308308
| `VGI_WORKER_ACCESS_LOG_QUEUE_SIZE` | Bound on the async access-log queue (default 10000) |
@@ -426,7 +426,7 @@ Also available on `Worker.main --http` and the fixture server. Under
426426

427427
### Token Introspection (`resolve_token`)
428428

429-
A worker may optionally host the `vgi_rpc.Identity.v1` protocol, whose
429+
A worker may optionally host the `vgi_rpc.Identity.v2` protocol, whose
430430
`introspect_token` method resolves an opaque bearer credential to a principal —
431431
for a reverse proxy that terminates the only public listener and must know the
432432
caller's identity before it can authorize anything.
@@ -509,7 +509,9 @@ one. Allowlist rules are unchanged. See `docs/authentication.md` and
509509

510510
### Attach Tickets (`vgi.attach_tickets.v1`)
511511

512-
`seal_attach` seals a user's ATTACH (catalog name, options incl. secrets,
512+
Protocol version `2.0.0`: `AttachTicket.expires_at` is nullable
513+
`timestamp[us, tz="UTC"]` (`datetime | None`, null = no expiry); `1.0.0` was a
514+
float64 with `+inf`. `seal_attach` seals a user's ATTACH (catalog name, options incl. secrets,
513515
version specs) into a `vgia1.` ticket under `VGI_SIGNING_KEY`, AAD
514516
`"vgi.attach_ticket.v1\0" + principal` (principal only, not domain). A runner
515517
holding the user's grant reattaches with the single option `vgi_attach_ticket`;

‎docs/api/auth.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ 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,
21+
As of vgi-rpc 0.46.0 introspection is the `vgi_rpc.Identity.v2` protocol hosted on the RPC server,
2222
not an HTTP JSON route, so clients discover it through reflection. VGI hosts it on HTTP only, the
2323
transport that authenticates callers: its allowlist names principals, which stdin/stdout, AF_UNIX
2424
and TCP do not have. `TokenIdentity` and `TokenResolver` are no longer gated on the `[http]` extra.

‎docs/api/worker.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ overriding the same hook stop it at startup.
7272

7373
Reflection (`list_protocols`) reports protocols in this order:
7474

75-
| Transport | `vgi.v2` | `hosted_protocols()` | `vgi_rpc.Reflection.v1` | `vgi_rpc.Identity.v1` |
75+
| Transport | `vgi.v2` | `hosted_protocols()` | `vgi_rpc.Reflection.v1` | `vgi_rpc.Identity.v2` |
7676
| --- | --- | --- | --- | --- |
7777
| stdin/stdout | yes | yes | yes | no |
7878
| AF_UNIX / named pipe | yes | yes | yes | no |

‎docs/authentication.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ lookup.
4141

4242
## Grants and resolved tokens as bearer credentials
4343

44-
`vgi_rpc.Identity.v1`'s `issue_grant` mints a standing credential that
44+
`vgi_rpc.Identity.v2`'s `issue_grant` mints a standing credential that
4545
unattended automation later presents as an ordinary bearer. Over HTTP the
4646
worker accepts two kinds of identity credential after the deployment's own
4747
authenticator:

‎docs/protocol/vgi-attach-tickets.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
# VGI Attach Tickets
22

33
**Status:** Normative. Reference implementation: vgi-python `vgi/attach_ticket.py`.
4-
**Protocol:** `vgi.attach_tickets.v1`, version `1.0.0`. No change to `vgi.v2`.
4+
**Protocol:** `vgi.attach_tickets.v1`, version `2.0.0`. No change to `vgi.v2`.
5+
**Revision 2026-10-17 (`2.0.0`):** `AttachTicket.expires_at` changed from
6+
`float64` unix seconds (`+inf` = no expiry) to nullable
7+
`timestamp[us, tz="UTC"]` (null = no expiry). The sealed payload (§2) is
8+
unchanged; the exact major.minor version check rejects a `1.0.0` peer.
59
**Vectors:** [`vgi/_test_fixtures/attach_ticket_vectors.json`](https://github.com/Query-farm/vgi-python/blob/main/vgi/_test_fixtures/attach_ticket_vectors.json)
610

711
An SDK implements attach tickets from this page alone. The words MUST, MUST
@@ -17,7 +21,7 @@ this worker can open. Later a runner attaches with one option,
1721
`vgi_attach_ticket`, and the worker restores the original attach before any
1822
catalog code runs.
1923

20-
A ticket says *what* to attach. A sealed grant (`vgi_rpc.Identity.v1`
24+
A ticket says *what* to attach. A sealed grant (`vgi_rpc.Identity.v2`
2125
`issue_grant`) says *who* attaches. The two are separate on purpose:
2226

2327
- **A ticket carries no authority.** It opens only under the caller's
@@ -133,7 +137,7 @@ from `vgi_rpc.Reflection.v1` `list_protocols`):
133137
is useless without a grant.
134138

135139
It is hosted beside `vgi.v2` and never changes how a `vgi.v2` request is
136-
dispatched. Wire name `vgi.attach_tickets.v1`, `protocol_version = "1.0.0"`.
140+
dispatched. Wire name `vgi.attach_tickets.v1`, `protocol_version = "2.0.0"`.
137141

138142
### 5.2 Shapes
139143

@@ -154,7 +158,7 @@ One unary method, `seal_attach(request: SealAttachRequest) -> AttachTicket`.
154158
| Field | Arrow type | Meaning |
155159
| --- | --- | --- |
156160
| `ticket` | `utf8` not null | The `vgia1.` text |
157-
| `expires_at` | `float64` not null | Unix seconds; `+inf` when the ticket has no expiry |
161+
| `expires_at` | `timestamp[us, tz="UTC"]` nullable | The UTC instant the ticket expires (the sealed `expires_at` seconds × 1 000 000 µs); null when the ticket has no expiry |
158162

159163
### 5.3 Rules
160164

@@ -194,7 +198,7 @@ In this order:
194198

195199
So the two halves of a held entry (grant and ticket) expire together.
196200
5. **Seal** per §2 with a fresh `ticket_id` and nonce. Return the text and
197-
`expires_at` (`+inf` for `0`).
201+
`expires_at` (null for `0`).
198202

199203
A worker MAY refuse by policy with `action_denied`.
200204

‎scripts/hosted_protocols_conformance.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
#
77
# stdio vgi-fixture-worker (MetaWorker.serve, pipe)
88
# unix vgi-fixture-worker --unix <sock> (the DuckDB launcher path)
9-
# http vgi-fixture-http --identity (+ the Identity.v1 groups)
9+
# http vgi-fixture-http --identity (+ the Identity.v2 groups)
1010
#
1111
# Every worker must host, in order, vgi.v2 then conformance.Secondary.v1.
1212
#

‎tests/test_attach_ticket.py‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
import base64
2323
import importlib.util
2424
import json
25-
import math
2625
import time
2726
from collections.abc import Iterator
27+
from datetime import UTC, datetime, timedelta
2828
from pathlib import Path
2929
from typing import Annotated, Any
3030

@@ -68,7 +68,7 @@
6868

6969
_GRANT_KEY_B64 = base64.b64encode(bytes(range(32))).decode()
7070
_SIGNING_KEY = b"attach-ticket-test-signing-key-1"
71-
_IDENTITY = "vgi_rpc.Identity.v1"
71+
_IDENTITY = "vgi_rpc.Identity.v2"
7272

7373

7474
@pytest.fixture(autouse=True)
@@ -293,7 +293,7 @@ class _MintingWorker(TicketProbeWorker):
293293
@classmethod
294294
def mint_grant(cls, principal: str, purpose: str, scopes: list[str], ttl_seconds: int) -> IssuedGrant:
295295
"""Mint grant."""
296-
return IssuedGrant(token=f"own-{principal}", expires_at=time.time() + ttl_seconds)
296+
return IssuedGrant(token=f"own-{principal}", expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds))
297297

298298

299299
def _server(worker: Any, *, transport: str = "http", configured: bool = True, keys: bool = True) -> Any:
@@ -469,15 +469,15 @@ def test_option_names_are_case_insensitive(self) -> None:
469469
[(0, 3600, 3600), (60, 3600, 60), (99999, 3600, 3600), (0, None, None), (60, None, 60)],
470470
)
471471
def test_lifetime(self, ttl: int, ceiling: int | None, expected: int | None) -> None:
472-
"""expires_at = min(requested, grant maximum); 0 asks for the maximum; no maximum is +inf."""
472+
"""expires_at = min(requested, grant maximum); 0 asks for the maximum; no maximum is null."""
473473
before = int(time.time())
474474
ticket = _seal(_impl(ceiling), ttl_seconds=ttl)
475475
claims = open_attach_ticket(_SIGNING_KEY, ticket.ticket, principal="alice")
476476
if expected is None:
477-
assert claims.expires_at == 0 and math.isinf(ticket.expires_at)
477+
assert claims.expires_at == 0 and ticket.expires_at is None
478478
else:
479479
assert before + expected <= claims.expires_at <= int(time.time()) + expected
480-
assert ticket.expires_at == claims.expires_at
480+
assert ticket.expires_at == datetime.fromtimestamp(claims.expires_at, tz=UTC)
481481

482482
def test_max_ttl_from_grant_keys_and_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
483483
"""Max ttl from grant keys and env."""
@@ -666,7 +666,8 @@ def test_grant_plus_ticket_reattaches_as_the_user(self, app: Any) -> None:
666666
with http_connect(Identity, client=user) as identity: # type: ignore[type-abstract]
667667
grant = identity.issue_grant(purpose="vgi.unattended", scopes=[], ttl_seconds=600)
668668
assert sealed.ticket.startswith("vgia1.") and "sk-live-secret" not in sealed.ticket
669-
assert sealed.expires_at <= time.time() + 7 * 24 * 3600 + 5
669+
assert sealed.expires_at is not None
670+
assert sealed.expires_at <= datetime.now(UTC) + timedelta(days=7, seconds=5)
670671

671672
runner = _SyncTestClient(app, default_headers={"Authorization": f"Bearer {grant.token}"})
672673
reattached = _attach_over_http(runner, {"vgi_attach_ticket": sealed.ticket})
@@ -738,3 +739,28 @@ def test_fixture_http_server_hosts_attach_tickets_when_keyed(http_worker: Any) -
738739
with http_connect(Reflection, base_url) as reflection: # type: ignore[type-abstract]
739740
hosted = {p.protocol for p in reflection.list_protocols().protocols}
740741
assert ATTACH_TICKETS_PROTOCOL_NAME in hosted
742+
743+
744+
class TestAttachTicketWireShape:
745+
"""Protocol ``2.0.0``: ``expires_at`` is a nullable UTC microsecond timestamp."""
746+
747+
def test_protocol_version(self) -> None:
748+
"""The exact major.minor check rejects a ``1.0.0`` (float64 ``expires_at``) peer."""
749+
assert AttachTickets.protocol_version == "2.0.0"
750+
751+
def test_expires_at_is_a_nullable_utc_timestamp(self) -> None:
752+
"""``timestamp[us, tz="UTC"]``, nullable; null replaces ``+inf``."""
753+
field = AttachTicket.ARROW_SCHEMA.field("expires_at")
754+
assert field.type == pa.timestamp("us", tz="UTC")
755+
assert field.nullable
756+
757+
@pytest.mark.parametrize("when", [None, datetime(2026, 9, 21, 12, 0, 0, 123456, tzinfo=UTC)])
758+
def test_round_trip(self, when: datetime | None) -> None:
759+
"""Null and a microsecond-precision instant both survive the wire."""
760+
got = AttachTicket.deserialize_from_bytes(AttachTicket(ticket="vgia1.x", expires_at=when).serialize_to_bytes())
761+
assert got.expires_at == when
762+
763+
def test_a_naive_datetime_is_refused(self) -> None:
764+
"""Arrow would read a naive value as UTC, silently shifting local time."""
765+
with pytest.raises(ValueError, match="timezone-aware"):
766+
AttachTicket(ticket="vgia1.x", expires_at=datetime(2026, 1, 1))

‎tests/test_grant_auth.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
"""Identity credentials accepted as HTTP bearers: sealed grants and ``resolve_token``.
44
5-
vgi-rpc's ``Identity.v1`` mints grants "presented later by unattended
5+
vgi-rpc's ``Identity.v2`` mints grants "presented later by unattended
66
automation as an ordinary bearer". These tests close that loop through
77
vgi-python's own HTTP surface (``create_app``), not vgi-rpc in isolation:
88
@@ -42,7 +42,7 @@
4242
_KEY_B64 = base64.b64encode(bytes(range(32))).decode()
4343
_OTHER_KEY_B64 = base64.b64encode(bytes(range(32, 64))).decode()
4444
_SIGNING_KEY = b"grant-auth-test-signing-key-0001"
45-
_IDENTITY = "vgi_rpc.Identity.v1"
45+
_IDENTITY = "vgi_rpc.Identity.v2"
4646
_GRANT_ENV = (GRANT_KEYS_ENV, "VGI_RPC_GRANT_AUDIENCE", "VGI_RPC_GRANT_MAX_TTL_SECONDS")
4747

4848
_VECTORS = json.loads(files("vgi_rpc.conformance").joinpath("grant_token_vectors.json").read_text(encoding="utf-8"))

‎tests/test_hosted_protocols.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
_EXTRAS = ["vgi_test.Echo.v1", "vgi_test.Counter.v1"]
4040
_EXPECTED = ["vgi.v2", *_EXTRAS, "vgi_rpc.Reflection.v1"]
41-
_IDENTITY = "vgi_rpc.Identity.v1"
41+
_IDENTITY = "vgi_rpc.Identity.v2"
4242

4343
# Protocol classes are abstract to mypy; the connect helpers want concrete types.
4444
_REFLECTION: type[Any] = Reflection
@@ -49,7 +49,7 @@
4949

5050

5151
class _IdentityHostingWorker(HostingWorker):
52-
"""Opts into ``vgi_rpc.Identity.v1`` as well."""
52+
"""Opts into ``vgi_rpc.Identity.v2`` as well."""
5353

5454
@classmethod
5555
def resolve_token(cls, token: str) -> TokenIdentity | None:

0 commit comments

Comments
 (0)