regent-httpsig 0.4.0__tar.gz → 0.5.0__tar.gz
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.
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/CHANGELOG.md +24 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/PKG-INFO +5 -3
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/README.md +4 -2
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/pyproject.toml +1 -1
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/budget.py +60 -27
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/fastapi.py +15 -9
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_budget.py +28 -16
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_usage.py +1 -1
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/vectors/aauth-budgets-vectors.json +8 -13
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/vectors/generate.py +9 -8
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/.github/workflows/ci.yml +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/.github/workflows/release.yml +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/.gitignore +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/LICENSE +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/SECURITY.md +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/examples/fastapi_verify.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/examples/httpx_signer.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/__init__.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/cli.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/config.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/jwk.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/netguard.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/sfv.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/sign.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/usage.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/src/regent_httpsig/verify.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_netguard.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_signer.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_vectors.py +0 -0
- {regent_httpsig-0.4.0 → regent_httpsig-0.5.0}/tests/test_verifier.py +0 -0
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
**AAuth Budgets — per-token cap, one consumption record** (draft-hardt-aauth-budgets
|
|
6
|
+
editor's copy, September 2026; issue #120):
|
|
7
|
+
|
|
8
|
+
- **No more pooling.** `InMemoryMeter` caps each auth token at its own `budget`
|
|
9
|
+
(§Aggregation): committed consumption plus outstanding reservations against
|
|
10
|
+
the presented `jti` never exceed *its* grant, and a token never draws on a
|
|
11
|
+
sibling's allocation. The `(iss, sub, aud)` key survives as the per-person
|
|
12
|
+
**ledger** for records and usage counters — it is not a second ceiling.
|
|
13
|
+
0.4 pooled a person's live grants into one purse, which let a jti spend past
|
|
14
|
+
its own grant; the overflow, spent from a sibling's allocation, was never
|
|
15
|
+
attributed and the sibling's remainder later released as unspent. The
|
|
16
|
+
`required` member makes a fragmented agent's re-authorization a calculation,
|
|
17
|
+
so the purse bought nothing worth that.
|
|
18
|
+
- **One record on the wire.** Budget refusals carry a single
|
|
19
|
+
`budget_consumed` object — `{"jti", "consumed"}` for the PRESENTED token —
|
|
20
|
+
per §The Consumption Record. New `InMemoryMeter.consumed_record(key, jti)`;
|
|
21
|
+
`consumed_records(key, jkt=…)` stays as the audit view.
|
|
22
|
+
- **Breaking:** `InMemoryMeter.remaining(key)` → `remaining(key, jti)`;
|
|
23
|
+
`resource_token_provider(key, records: list)` → `(key, record: dict | None)`.
|
|
24
|
+
`InsufficientBudget.remaining` is now the presented token's balance.
|
|
25
|
+
- Test vectors regenerated (`consumption_records` is one object).
|
|
26
|
+
|
|
3
27
|
## 0.4.0
|
|
4
28
|
|
|
5
29
|
**AAuth Budgets — the August 20 editor's-copy additions** (allocation model,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: regent-httpsig
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth.
|
|
5
5
|
Project-URL: Homepage, https://github.com/regent-protocol/regent-httpsig
|
|
6
6
|
Project-URL: Repository, https://github.com/regent-protocol/regent-httpsig
|
|
@@ -146,8 +146,10 @@ to the control plane. The middleware does the whole resource-side checklist:
|
|
|
146
146
|
verify the token against your pinned PS, atomically reserve → commit →
|
|
147
147
|
release per request, answer with `AAuth-Budget`, and refuse exhausted
|
|
148
148
|
envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
|
|
149
|
-
resource token with
|
|
150
|
-
so one agent never learns about a sibling's spending)
|
|
149
|
+
resource token with **one** consumption record — the presented token's
|
|
150
|
+
`{jti, consumed}` — so one agent never learns about a sibling's spending).
|
|
151
|
+
The cap is **per token**: a jti never draws on a sibling's grant; the
|
|
152
|
+
`(iss, sub, aud)` key is only the per-person ledger behind records and usage:
|
|
151
153
|
|
|
152
154
|
```python
|
|
153
155
|
from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
|
|
@@ -116,8 +116,10 @@ to the control plane. The middleware does the whole resource-side checklist:
|
|
|
116
116
|
verify the token against your pinned PS, atomically reserve → commit →
|
|
117
117
|
release per request, answer with `AAuth-Budget`, and refuse exhausted
|
|
118
118
|
envelopes with a `401` + `AAuth-Requirement` (optionally carrying your signed
|
|
119
|
-
resource token with
|
|
120
|
-
so one agent never learns about a sibling's spending)
|
|
119
|
+
resource token with **one** consumption record — the presented token's
|
|
120
|
+
`{jti, consumed}` — so one agent never learns about a sibling's spending).
|
|
121
|
+
The cap is **per token**: a jti never draws on a sibling's grant; the
|
|
122
|
+
`(iss, sub, aud)` key is only the per-person ledger behind records and usage:
|
|
121
123
|
|
|
122
124
|
```python
|
|
123
125
|
from regent_httpsig import HttpsigConfig, HttpsigVerifier, InMemoryMeter
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "regent-httpsig"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Verify and sign AI agent HTTP traffic in Python — RFC 9421 HTTP Message Signatures: Web Bot Auth (what OpenAI ships) and AAuth."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "Apache-2.0"
|
|
@@ -5,11 +5,23 @@ The auth token carries a spending envelope::
|
|
|
5
5
|
"budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
|
|
6
6
|
|
|
7
7
|
and the resource meters every request against it: reserve the request's maximum
|
|
8
|
-
cost atomically, serve, commit the actual cost, release the difference.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
cost atomically, serve, commit the actual cost, release the difference.
|
|
9
|
+
|
|
10
|
+
Two things are counted, against different keys (draft §Aggregation):
|
|
11
|
+
|
|
12
|
+
* the **cap** is per auth token — committed consumption plus outstanding
|
|
13
|
+
reservations against the presented ``jti`` never exceed *its* ``budget``;
|
|
14
|
+
no cross-token arithmetic, one token never draws on a sibling's grant;
|
|
15
|
+
* the **ledger** is per person — consumption is posted to ``(iss, sub, aud)``
|
|
16
|
+
for the consumption record and the usage counters. It is not a second
|
|
17
|
+
ceiling: a request that fits its token's budget is never refused because
|
|
18
|
+
of a per-person total.
|
|
19
|
+
|
|
20
|
+
(0.4 and earlier pooled a principal's live grants into one purse. That let a
|
|
21
|
+
jti spend past its own grant, so the figure recorded against it could exceed
|
|
22
|
+
what its issuer granted and the overflow — spent from a sibling's allocation —
|
|
23
|
+
was never attributed. Dropped in 0.5.0; the ``required`` member makes a
|
|
24
|
+
fragmented agent's re-authorization a calculation instead.)
|
|
13
25
|
|
|
14
26
|
Everything here is framework-free; the FastAPI glue lives in
|
|
15
27
|
:mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
|
|
@@ -42,7 +54,7 @@ class InvalidBudgetClaim(ValueError):
|
|
|
42
54
|
|
|
43
55
|
|
|
44
56
|
class UnitMismatch(ValueError):
|
|
45
|
-
"""A grant's unit/decimals differ from the
|
|
57
|
+
"""A grant's unit/decimals differ from the ledger's — one envelope, one unit."""
|
|
46
58
|
|
|
47
59
|
|
|
48
60
|
@dataclass(frozen=True)
|
|
@@ -80,8 +92,8 @@ class BudgetClaim:
|
|
|
80
92
|
|
|
81
93
|
@dataclass(frozen=True)
|
|
82
94
|
class Reservation:
|
|
83
|
-
"""An atomic hold on
|
|
84
|
-
committed (with the actual cost) or released, exactly once."""
|
|
95
|
+
"""An atomic hold on one token's budget for one in-flight request. Never
|
|
96
|
+
revised — committed (with the actual cost) or released, exactly once."""
|
|
85
97
|
|
|
86
98
|
rid: int
|
|
87
99
|
key: MeterKey
|
|
@@ -91,7 +103,8 @@ class Reservation:
|
|
|
91
103
|
|
|
92
104
|
@dataclass(frozen=True)
|
|
93
105
|
class InsufficientBudget:
|
|
94
|
-
"""Refusal: the request's maximum cost exceeds the
|
|
106
|
+
"""Refusal: the request's maximum cost exceeds the presented token's
|
|
107
|
+
remaining balance.
|
|
95
108
|
``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
|
|
96
109
|
(``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
|
|
97
110
|
|
|
@@ -204,21 +217,25 @@ class InMemoryMeter:
|
|
|
204
217
|
return pool
|
|
205
218
|
|
|
206
219
|
@staticmethod
|
|
207
|
-
def _remaining(pool: _Pool) -> int:
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
220
|
+
def _remaining(pool: _Pool, jti: str) -> int:
|
|
221
|
+
"""The presented token's balance: its grant minus what was committed
|
|
222
|
+
against it minus what is held for it. Sibling tokens of the same
|
|
223
|
+
person do not enter — the cap is per token (§Aggregation)."""
|
|
224
|
+
grant = pool.grants.get(jti)
|
|
225
|
+
if grant is None:
|
|
226
|
+
return 0
|
|
227
|
+
held = sum(a for j, a, _ in pool.reservations.values() if j == jti)
|
|
228
|
+
return max(0, grant[0] - pool.consumed.get(jti, 0) - held)
|
|
212
229
|
|
|
213
230
|
# ── public interface (the BudgetMeter contract) ──────────────────────────
|
|
214
231
|
|
|
215
232
|
async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
|
|
216
233
|
exp: float, jkt: str = "") -> None:
|
|
217
|
-
"""Register a token's envelope
|
|
218
|
-
``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
|
|
234
|
+
"""Register a token's envelope under the person's ledger key (idempotent
|
|
235
|
+
per ``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
|
|
219
236
|
recorded so consumption records can be scoped to the presenting agent
|
|
220
237
|
(one agent must not learn about its siblings). Raises
|
|
221
|
-
:class:`UnitMismatch` if the
|
|
238
|
+
:class:`UnitMismatch` if the ledger already runs in a different unit —
|
|
222
239
|
one envelope, one unit, no FX at the meter."""
|
|
223
240
|
async with self._lock:
|
|
224
241
|
now = time.monotonic()
|
|
@@ -229,7 +246,7 @@ class InMemoryMeter:
|
|
|
229
246
|
key, _Pool(unit=claim.unit, decimals=claim.decimals))
|
|
230
247
|
if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
|
|
231
248
|
raise UnitMismatch(
|
|
232
|
-
f"
|
|
249
|
+
f"ledger runs in {pool.unit}/{pool.decimals}, "
|
|
233
250
|
f"grant is {claim.unit}/{claim.decimals}")
|
|
234
251
|
pool.last_activity = now
|
|
235
252
|
if jkt:
|
|
@@ -244,7 +261,7 @@ class InMemoryMeter:
|
|
|
244
261
|
pool = self._purge(key, now)
|
|
245
262
|
if pool is None or jti not in pool.grants:
|
|
246
263
|
return InsufficientBudget(remaining=0, exhausted=True)
|
|
247
|
-
remaining = self._remaining(pool)
|
|
264
|
+
remaining = self._remaining(pool, jti)
|
|
248
265
|
if max_cost > remaining:
|
|
249
266
|
return InsufficientBudget(remaining=remaining,
|
|
250
267
|
exhausted=remaining == 0)
|
|
@@ -255,7 +272,7 @@ class InMemoryMeter:
|
|
|
255
272
|
|
|
256
273
|
async def commit(self, res: Reservation, actual: int) -> int:
|
|
257
274
|
"""Commit the actual cost (clamped to the reserved amount — reservations
|
|
258
|
-
are never revised upward) and return the
|
|
275
|
+
are never revised upward) and return the token's remaining balance."""
|
|
259
276
|
async with self._lock:
|
|
260
277
|
now = time.monotonic()
|
|
261
278
|
pool = self._purge(res.key, now)
|
|
@@ -267,7 +284,7 @@ class InMemoryMeter:
|
|
|
267
284
|
pool.last_activity = now
|
|
268
285
|
if cost > 0:
|
|
269
286
|
self._record_usage(res.key, pool, res.jti, cost)
|
|
270
|
-
return self._remaining(pool)
|
|
287
|
+
return self._remaining(pool, res.jti)
|
|
271
288
|
|
|
272
289
|
async def release(self, res: Reservation) -> int:
|
|
273
290
|
async with self._lock:
|
|
@@ -275,12 +292,14 @@ class InMemoryMeter:
|
|
|
275
292
|
if pool is None:
|
|
276
293
|
return 0
|
|
277
294
|
pool.reservations.pop(res.rid, None)
|
|
278
|
-
return self._remaining(pool)
|
|
295
|
+
return self._remaining(pool, res.jti)
|
|
279
296
|
|
|
280
|
-
async def remaining(self, key: MeterKey) -> int:
|
|
297
|
+
async def remaining(self, key: MeterKey, jti: str) -> int:
|
|
298
|
+
"""The presented token's remaining balance (0 for an unknown or
|
|
299
|
+
expired ``jti``)."""
|
|
281
300
|
async with self._lock:
|
|
282
301
|
pool = self._purge(key, time.monotonic())
|
|
283
|
-
return 0 if pool is None else self._remaining(pool)
|
|
302
|
+
return 0 if pool is None else self._remaining(pool, jti)
|
|
284
303
|
|
|
285
304
|
def _record_usage(self, key: MeterKey, pool: _Pool, jti: str,
|
|
286
305
|
amount: int) -> None:
|
|
@@ -322,11 +341,25 @@ class InMemoryMeter:
|
|
|
322
341
|
or ``None`` before the first commit."""
|
|
323
342
|
return self._metering_unit
|
|
324
343
|
|
|
344
|
+
async def consumed_record(self, key: MeterKey, jti: str) -> dict[str, Any] | None:
|
|
345
|
+
"""The consumption record for the resource token's ``budget_consumed``
|
|
346
|
+
claim (draft §The Consumption Record): ``{"jti", "consumed"}`` for the
|
|
347
|
+
PRESENTED token — its total metered so far — or ``None`` when nothing
|
|
348
|
+
was metered against it. One record, the presented token's; the spend
|
|
349
|
+
under a person's other tokens is the usage endpoint's to report."""
|
|
350
|
+
async with self._lock:
|
|
351
|
+
pool = self._purge(key, time.monotonic())
|
|
352
|
+
if pool is None:
|
|
353
|
+
return None
|
|
354
|
+
total = pool.consumed.get(jti, 0)
|
|
355
|
+
return {"jti": jti, "consumed": total} if total > 0 else None
|
|
356
|
+
|
|
325
357
|
async def consumed_records(self, key: MeterKey,
|
|
326
358
|
jkt: str | None = None) -> list[dict[str, Any]]:
|
|
327
|
-
"""
|
|
328
|
-
|
|
329
|
-
|
|
359
|
+
"""Audit view: per-token consumption under this ledger key,
|
|
360
|
+
``[{"jti": ..., "consumed": ...}, ...]``. Not what goes on the wire —
|
|
361
|
+
the resource token carries :meth:`consumed_record` — but the figures a
|
|
362
|
+
PS-side reconciliation or an operator wants to see.
|
|
330
363
|
|
|
331
364
|
When ``jkt`` is given, records are scoped to tokens bound to that key:
|
|
332
365
|
the agent carrying the resource token sees only its OWN spending, never
|
|
@@ -122,7 +122,7 @@ RequiredSignatureDep = Depends(require_signature)
|
|
|
122
122
|
|
|
123
123
|
PriceFn = Callable[[Request], "int | None | Awaitable[int | None]"]
|
|
124
124
|
ResourceTokenProvider = Callable[
|
|
125
|
-
[MeterKey, "
|
|
125
|
+
[MeterKey, "dict[str, Any] | None"], "str | None | Awaitable[str | None]"
|
|
126
126
|
]
|
|
127
127
|
|
|
128
128
|
|
|
@@ -158,9 +158,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
158
158
|
``require=True`` refuses them with 401 + ``AAuth-Requirement``.
|
|
159
159
|
- Error responses (4xx/5xx) release the reservation — nothing was served,
|
|
160
160
|
the envelope is not charged.
|
|
161
|
-
- ``resource_token_provider(key,
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
- ``resource_token_provider(key, record)`` (optional) mints the resource
|
|
162
|
+
token embedded in budget-refusal responses so the agent can carry
|
|
163
|
+
``budget_consumed`` back to its PS for re-authorization. ``record`` is
|
|
164
|
+
the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
|
|
165
|
+
Record — one record, two members) or ``None`` when nothing was metered
|
|
166
|
+
against it yet.
|
|
164
167
|
"""
|
|
165
168
|
|
|
166
169
|
def __init__(
|
|
@@ -223,7 +226,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
223
226
|
reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
|
|
224
227
|
return await self._refusal_with_token(
|
|
225
228
|
reason=reason, envelope=envelope, remaining=outcome.remaining,
|
|
226
|
-
key=key,
|
|
229
|
+
key=key, jti=jti,
|
|
227
230
|
# `required` rides only on insufficient-budget: what THIS
|
|
228
231
|
# request needed, so the agent can lower its bound and retry.
|
|
229
232
|
required=int(max_cost) if reason == "insufficient-budget" else None,
|
|
@@ -247,7 +250,7 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
247
250
|
# when `cost` is omitted — with `remaining` already net of the
|
|
248
251
|
# hold, and commit when the stream completes. The agent recovers
|
|
249
252
|
# the exact figure from the next response's `remaining`.
|
|
250
|
-
remaining = await self._meter.remaining(key)
|
|
253
|
+
remaining = await self._meter.remaining(key, jti)
|
|
251
254
|
response.headers["AAuth-Budget"] = build_aauth_budget_header(
|
|
252
255
|
remaining=remaining, reserved=int(max_cost),
|
|
253
256
|
unit=envelope.unit, decimals=envelope.decimals,
|
|
@@ -311,14 +314,17 @@ class BudgetMiddleware(BaseHTTPMiddleware):
|
|
|
311
314
|
|
|
312
315
|
async def _refusal_with_token(
|
|
313
316
|
self, *, reason: str, envelope: BudgetClaim,
|
|
314
|
-
remaining: int, key: MeterKey,
|
|
317
|
+
remaining: int, key: MeterKey, jti: str,
|
|
315
318
|
required: int | None = None,
|
|
316
319
|
) -> Response:
|
|
317
320
|
token: str | None = None
|
|
318
321
|
if self._resource_token is not None:
|
|
319
322
|
try:
|
|
320
|
-
|
|
321
|
-
|
|
323
|
+
# One record, the presented token's (§The Consumption Record):
|
|
324
|
+
# what THIS jti has cost so far. Siblings' spend never rides
|
|
325
|
+
# home with this agent — the usage endpoint reports that.
|
|
326
|
+
record = await self._meter.consumed_record(key, jti)
|
|
327
|
+
token = await _maybe_await(self._resource_token(key, record))
|
|
322
328
|
except Exception: # noqa: BLE001 — refusal must not fail on the extras
|
|
323
329
|
logger.warning("resource_token_provider failed", exc_info=True)
|
|
324
330
|
return self._refusal(reason=reason, envelope=envelope,
|
|
@@ -105,19 +105,29 @@ async def test_meter_reserve_commit_release_math() -> None:
|
|
|
105
105
|
await meter.observe_grant(KEY, "jti-1", _claim(1000), time.time() + 60)
|
|
106
106
|
res = await meter.reserve(KEY, "jti-1", 300)
|
|
107
107
|
assert isinstance(res, Reservation)
|
|
108
|
-
assert await meter.remaining(KEY) == 700 # 300 held
|
|
108
|
+
assert await meter.remaining(KEY, "jti-1") == 700 # 300 held
|
|
109
109
|
assert await meter.commit(res, 120) == 880 # difference released
|
|
110
110
|
assert await meter.consumed_records(KEY) == [{"jti": "jti-1", "consumed": 120}]
|
|
111
111
|
|
|
112
112
|
|
|
113
|
-
async def
|
|
114
|
-
"""
|
|
113
|
+
async def test_meter_caps_each_token_at_its_own_budget() -> None:
|
|
114
|
+
"""§Aggregation: the cap is per auth token. Two live envelopes of one
|
|
115
|
+
person do NOT pool — a jti never draws on a sibling's grant, so the
|
|
116
|
+
figure recorded against it can never exceed what its issuer granted."""
|
|
115
117
|
meter = InMemoryMeter()
|
|
116
118
|
await meter.observe_grant(KEY, "a", _claim(300), time.time() + 60)
|
|
117
119
|
await meter.observe_grant(KEY, "b", _claim(300), time.time() + 60)
|
|
118
|
-
|
|
120
|
+
refusal = await meter.reserve(KEY, "a", 500) # would have fit a pooled purse
|
|
121
|
+
assert isinstance(refusal, InsufficientBudget)
|
|
122
|
+
assert refusal.remaining == 300 and not refusal.exhausted
|
|
123
|
+
res = await meter.reserve(KEY, "a", 300)
|
|
119
124
|
assert isinstance(res, Reservation)
|
|
120
|
-
assert await meter.commit(res,
|
|
125
|
+
assert await meter.commit(res, 300) == 0 # a is spent…
|
|
126
|
+
assert await meter.remaining(KEY, "b") == 300 # …b is untouched
|
|
127
|
+
assert await meter.consumed_record(KEY, "a") == {"jti": "a", "consumed": 300}
|
|
128
|
+
assert await meter.consumed_record(KEY, "b") is None
|
|
129
|
+
# The ledger still aggregates per person for records/usage.
|
|
130
|
+
assert await meter.consumed_records(KEY) == [{"jti": "a", "consumed": 300}]
|
|
121
131
|
|
|
122
132
|
|
|
123
133
|
async def test_meter_insufficient_vs_exhausted() -> None:
|
|
@@ -163,7 +173,7 @@ async def test_meter_expired_reservation_counts_as_consumed() -> None:
|
|
|
163
173
|
res = await meter.reserve(KEY, "jti-1", 400)
|
|
164
174
|
assert isinstance(res, Reservation)
|
|
165
175
|
# Handler "crashed": never commits. The next touch resolves it as consumed.
|
|
166
|
-
assert await meter.remaining(KEY) == 600
|
|
176
|
+
assert await meter.remaining(KEY, "jti-1") == 600
|
|
167
177
|
assert await meter.consumed_records(KEY) == [{"jti": "jti-1", "consumed": 400}]
|
|
168
178
|
|
|
169
179
|
|
|
@@ -176,9 +186,9 @@ async def test_meter_commit_clamps_to_reservation() -> None:
|
|
|
176
186
|
|
|
177
187
|
|
|
178
188
|
async def test_consumed_records_scoped_to_presenting_jkt() -> None:
|
|
179
|
-
"""Two agents of one
|
|
180
|
-
sees only ITS OWN consumption records — never its siblings' (privacy
|
|
181
|
-
no extra figures to infer the ceiling from)."""
|
|
189
|
+
"""Two agents of one person post to the same (iss, sub, aud) ledger, but
|
|
190
|
+
each sees only ITS OWN consumption records — never its siblings' (privacy
|
|
191
|
+
+ no extra figures to infer the ceiling from)."""
|
|
182
192
|
meter = InMemoryMeter()
|
|
183
193
|
now = time.time()
|
|
184
194
|
await meter.observe_grant(KEY, "jti-a", _claim(500), now + 60, jkt="jkt-agent-A")
|
|
@@ -194,15 +204,16 @@ async def test_consumed_records_scoped_to_presenting_jkt() -> None:
|
|
|
194
204
|
assert await meter.consumed_records(KEY, jkt="jkt-agent-B") == [
|
|
195
205
|
{"jti": "jti-b", "consumed": 250}
|
|
196
206
|
]
|
|
197
|
-
# Unscoped (PS-side / audit view) still returns the whole
|
|
207
|
+
# Unscoped (PS-side / audit view) still returns the whole ledger.
|
|
198
208
|
assert len(await meter.consumed_records(KEY)) == 2
|
|
199
209
|
|
|
200
210
|
|
|
201
211
|
async def test_refusal_records_scoped_to_presenter(
|
|
202
212
|
monkeypatch: pytest.MonkeyPatch,
|
|
203
213
|
) -> None:
|
|
204
|
-
"""The resource token embedded in a refusal carries
|
|
205
|
-
|
|
214
|
+
"""The resource token embedded in a refusal carries ONE record — the
|
|
215
|
+
presented token's (§The Consumption Record): sibling A's spend must not
|
|
216
|
+
ride home with agent B, and B's other tokens don't either."""
|
|
206
217
|
ps_priv, ps_jwk = _ps_pair()
|
|
207
218
|
agent_a = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
208
219
|
agent_b = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
|
|
@@ -216,15 +227,16 @@ async def test_refusal_records_scoped_to_presenter(
|
|
|
216
227
|
|
|
217
228
|
app = _app(_verifier(ps_jwk, monkeypatch), resource_token_provider=provider)
|
|
218
229
|
|
|
219
|
-
# A spends 300 (price of /v1/search)
|
|
230
|
+
# A spends 300 (price of /v1/search) of its 400; B spends 300 of its 400.
|
|
220
231
|
assert (await _post(app, "/v1/search",
|
|
221
232
|
_signed_headers(agent_a, token_a, "/v1/search"))).status_code == 200
|
|
222
233
|
assert (await _post(app, "/v1/search",
|
|
223
234
|
_signed_headers(agent_b, token_b, "/v1/search"))).status_code == 200
|
|
224
|
-
# B asks again: 300 >
|
|
235
|
+
# B asks again: 300 > 100 remaining on at-B → refusal carrying at-B's record only.
|
|
225
236
|
r = await _post(app, "/v1/search", _signed_headers(agent_b, token_b, "/v1/search"))
|
|
226
237
|
assert r.status_code == 401
|
|
227
|
-
assert provider_records == [
|
|
238
|
+
assert provider_records == [{"jti": "at-B", "consumed": 300}]
|
|
239
|
+
assert r.headers["AAuth-Budget"] == 'remaining=100, required=300, unit="KZT", decimals=2'
|
|
228
240
|
|
|
229
241
|
|
|
230
242
|
async def test_meter_concurrent_reserves_never_oversell() -> None:
|
|
@@ -380,7 +392,7 @@ async def test_middleware_refuses_when_insufficient(
|
|
|
380
392
|
assert (r2.headers["AAuth-Budget"] ==
|
|
381
393
|
'remaining=200, required=300, unit="KZT", decimals=2')
|
|
382
394
|
assert provider_calls and provider_calls[0][0] == (PS_ISS, "owner-1", RESOURCE)
|
|
383
|
-
assert provider_calls[0][1] ==
|
|
395
|
+
assert provider_calls[0][1] == {"jti": "at-1", "consumed": 300} # one record (#120)
|
|
384
396
|
|
|
385
397
|
|
|
386
398
|
async def test_middleware_actual_cost_from_handler(
|
|
@@ -142,7 +142,7 @@ async def test_expired_reservation_reaches_usage_counters() -> None:
|
|
|
142
142
|
await meter.observe_grant(KEY, "jti-1", CLAIM, time.time() + 3600, jkt=JKT_A)
|
|
143
143
|
await meter.reserve(KEY, "jti-1", 700)
|
|
144
144
|
await asyncio.sleep(0.02)
|
|
145
|
-
await meter.remaining(KEY) # triggers the purge
|
|
145
|
+
await meter.remaining(KEY, "jti-1") # triggers the purge
|
|
146
146
|
assert (await meter.usage_keys(KEY[0], [JKT_A]))[JKT_A] == 700
|
|
147
147
|
|
|
148
148
|
|
|
@@ -68,20 +68,15 @@
|
|
|
68
68
|
]
|
|
69
69
|
},
|
|
70
70
|
"consumption_records": {
|
|
71
|
-
"budget_consumed":
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
"jti": "at-1",
|
|
78
|
-
"consumed": 221200
|
|
79
|
-
}
|
|
80
|
-
],
|
|
71
|
+
"budget_consumed": {
|
|
72
|
+
"jti": "at-2",
|
|
73
|
+
"consumed": 431200
|
|
74
|
+
},
|
|
81
75
|
"notes": [
|
|
82
|
-
"two members and no more
|
|
83
|
-
"
|
|
84
|
-
"
|
|
76
|
+
"ONE record \u2014 the presented token's \u2014 two members and no more; the issuer joins the rest from its ledger (#120)",
|
|
77
|
+
"consumed is that token's total as of the resource token's iat",
|
|
78
|
+
"final vs snapshot is decided by the issuer: resource token iat >= auth token exp is the final figure and releases the remainder; a record on a live token releases nothing",
|
|
79
|
+
"the spend under a person's other tokens is the usage endpoint's to report \u2014 never carried by a sibling"
|
|
85
80
|
]
|
|
86
81
|
},
|
|
87
82
|
"signed_usage_response": {
|
|
@@ -87,15 +87,16 @@ def budget_claim() -> dict:
|
|
|
87
87
|
|
|
88
88
|
def consumption_records() -> dict:
|
|
89
89
|
return {
|
|
90
|
-
"budget_consumed":
|
|
91
|
-
{"jti": "at-2", "consumed": 431200},
|
|
92
|
-
{"jti": "at-1", "consumed": 221200},
|
|
93
|
-
],
|
|
90
|
+
"budget_consumed": {"jti": "at-2", "consumed": 431200},
|
|
94
91
|
"notes": [
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
92
|
+
"ONE record — the presented token's — two members and no more; "
|
|
93
|
+
"the issuer joins the rest from its ledger (#120)",
|
|
94
|
+
"consumed is that token's total as of the resource token's iat",
|
|
95
|
+
"final vs snapshot is decided by the issuer: resource token iat >= "
|
|
96
|
+
"auth token exp is the final figure and releases the remainder; "
|
|
97
|
+
"a record on a live token releases nothing",
|
|
98
|
+
"the spend under a person's other tokens is the usage endpoint's "
|
|
99
|
+
"to report — never carried by a sibling",
|
|
99
100
|
],
|
|
100
101
|
}
|
|
101
102
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|