Skip to content

Commit 6f97330

Browse files
committed
Add AAuth Budgets resource-side support (draft-hardt-aauth-budgets)
- budget.py: BudgetClaim parsing, (iss,sub,aud)-pooled InMemoryMeter with atomic reserve/commit/release and conservative crash-safety - verify.py: aa-auth+jwt branch (config-pinned trusted PS JWKS, aud check, <=1h lifetime, cnf PoP); budget claim surfaced - sfv.py: AAuth-Budget / AAuth-Requirement serialization (RFC 9651 dictionary) - fastapi.py: BudgetMiddleware — price_fn hook, metering cycle, refusals with resource-token provider, per-response AAuth-Budget header - 29 new tests; 61 total green; mypy strict clean
1 parent ee7d32a commit 6f97330

7 files changed

Lines changed: 959 additions & 20 deletions

File tree

src/regent_httpsig/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,44 @@
44
(draft-hardt), in plain Python. See https://github.com/regent-protocol/regent-httpsig
55
"""
66

7+
from regent_httpsig.budget import (
8+
BudgetClaim,
9+
InMemoryMeter,
10+
InsufficientBudget,
11+
InvalidBudgetClaim,
12+
UnitMismatch,
13+
)
714
from regent_httpsig.config import HttpsigConfig
815
from regent_httpsig.jwk import b64url, jwk_thumbprint, load_ed25519_jwk
916
from regent_httpsig.netguard import NotPublicURL, assert_public_url
10-
from regent_httpsig.sfv import parse_signature_agent
17+
from regent_httpsig.sfv import (
18+
build_aauth_budget_header,
19+
build_aauth_requirement,
20+
parse_signature_agent,
21+
)
1122
from regent_httpsig.sign import DIRECTORY_MEDIA_TYPE, EgressSigner, generate_seed
1223
from regent_httpsig.verify import WBA_TAG, HttpsigVerifier, VerifiedSignature
1324

1425
__version__ = "0.2.0"
1526

1627
__all__ = [
1728
"DIRECTORY_MEDIA_TYPE",
29+
"BudgetClaim",
1830
"EgressSigner",
1931
"HttpsigConfig",
2032
"HttpsigVerifier",
33+
"InMemoryMeter",
34+
"InsufficientBudget",
35+
"InvalidBudgetClaim",
2136
"NotPublicURL",
37+
"UnitMismatch",
2238
"VerifiedSignature",
2339
"WBA_TAG",
2440
"__version__",
2541
"assert_public_url",
2642
"b64url",
43+
"build_aauth_budget_header",
44+
"build_aauth_requirement",
2745
"generate_seed",
2846
"jwk_thumbprint",
2947
"load_ed25519_jwk",

src/regent_httpsig/budget.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""AAuth Budgets (draft-hardt-aauth-budgets, editor's copy) — resource-side core.
2+
3+
The auth token carries a spending envelope::
4+
5+
"budget": { "amount": 2000000, "unit": "USD", "decimals": 6 } # = $2.00
6+
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. The
9+
draft requires consumption to be aggregated atomically across all live auth
10+
tokens for the key ``(iss, sub, aud)`` (§14.4), so the meter pools the grants
11+
of a principal's live tokens and counts reservations + consumption against
12+
that pool.
13+
14+
Everything here is framework-free; the FastAPI glue lives in
15+
:mod:`regent_httpsig.fastapi` (``BudgetMiddleware``).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import itertools
22+
import time
23+
from collections.abc import Mapping
24+
from dataclasses import dataclass, field
25+
from typing import Any
26+
27+
__all__ = [
28+
"BudgetClaim",
29+
"InMemoryMeter",
30+
"InsufficientBudget",
31+
"InvalidBudgetClaim",
32+
"Reservation",
33+
"UnitMismatch",
34+
]
35+
36+
MeterKey = tuple[str, str, str] # (iss, sub, aud) — the draft's aggregation key
37+
38+
39+
class InvalidBudgetClaim(ValueError):
40+
"""A ``budget`` member is present but malformed (issuer bug — not spendable)."""
41+
42+
43+
class UnitMismatch(ValueError):
44+
"""A grant's unit/decimals differ from the pool's — one envelope, one unit."""
45+
46+
47+
@dataclass(frozen=True)
48+
class BudgetClaim:
49+
"""The ``budget`` claim: integer amount in ``unit`` scaled by ``decimals``.
50+
51+
``amount=5000000, unit="USD", decimals=6`` is $5.00 — all arithmetic stays
52+
in integers; the scale only matters at display time.
53+
"""
54+
55+
amount: int
56+
unit: str
57+
decimals: int
58+
59+
@staticmethod
60+
def parse(claims: Mapping[str, Any]) -> BudgetClaim | None:
61+
"""Extract the claim from a token's claim set. ``None`` when absent;
62+
:class:`InvalidBudgetClaim` when present but malformed (all three
63+
members are REQUIRED, integers must be non-negative, bools are not
64+
integers here)."""
65+
raw = claims.get("budget")
66+
if raw is None:
67+
return None
68+
if not isinstance(raw, Mapping):
69+
raise InvalidBudgetClaim("budget claim must be an object")
70+
amount, unit, decimals = raw.get("amount"), raw.get("unit"), raw.get("decimals")
71+
if (
72+
isinstance(amount, bool) or not isinstance(amount, int) or amount < 0
73+
or not isinstance(unit, str) or not unit
74+
or isinstance(decimals, bool) or not isinstance(decimals, int) or decimals < 0
75+
):
76+
raise InvalidBudgetClaim("budget claim requires amount/unit/decimals")
77+
return BudgetClaim(amount=amount, unit=unit, decimals=decimals)
78+
79+
80+
@dataclass(frozen=True)
81+
class Reservation:
82+
"""An atomic hold on the pool for one in-flight request. Never revised —
83+
committed (with the actual cost) or released, exactly once."""
84+
85+
rid: int
86+
key: MeterKey
87+
jti: str
88+
amount: int
89+
90+
91+
@dataclass(frozen=True)
92+
class InsufficientBudget:
93+
"""Refusal: the request's maximum cost exceeds the pool's remaining balance.
94+
``exhausted`` distinguishes the draft's two reason tokens: an empty envelope
95+
(``budget-exhausted``) vs a too-expensive request (``insufficient-budget``)."""
96+
97+
remaining: int
98+
exhausted: bool
99+
100+
101+
@dataclass
102+
class _Pool:
103+
unit: str
104+
decimals: int
105+
grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
106+
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
107+
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
108+
last_activity: float = 0.0
109+
110+
111+
class InMemoryMeter:
112+
"""Single-process meter (asyncio-safe). Right for a single-instance service;
113+
multi-replica deployments need a shared backend behind the same interface.
114+
115+
Crash-safety is conservative: a reservation not committed or released within
116+
``reservation_ttl`` seconds is treated as fully consumed — the owner's
117+
envelope is never silently under-counted by a crashed handler.
118+
"""
119+
120+
def __init__(self, *, reservation_ttl: float = 120.0,
121+
retention_seconds: float = 7200.0) -> None:
122+
self._pools: dict[MeterKey, _Pool] = {}
123+
self._lock = asyncio.Lock()
124+
self._rids = itertools.count(1)
125+
self._reservation_ttl = reservation_ttl
126+
self._retention = retention_seconds
127+
128+
# ── internals (call under lock) ──────────────────────────────────────────
129+
130+
def _purge(self, key: MeterKey, now: float) -> _Pool | None:
131+
pool = self._pools.get(key)
132+
if pool is None:
133+
return None
134+
# Expired, unresolved reservations count as consumed (conservative).
135+
for rid, (jti, amount, deadline) in list(pool.reservations.items()):
136+
if deadline <= now:
137+
pool.consumed[jti] = pool.consumed.get(jti, 0) + amount
138+
del pool.reservations[rid]
139+
# Expired grants leave the pool; their consumption records remain for
140+
# budget_consumed reporting until the retention window passes.
141+
for jti, (_, exp) in list(pool.grants.items()):
142+
if exp <= now:
143+
del pool.grants[jti]
144+
if (not pool.grants and not pool.reservations
145+
and now - pool.last_activity > self._retention):
146+
del self._pools[key]
147+
return None
148+
return pool
149+
150+
@staticmethod
151+
def _remaining(pool: _Pool) -> int:
152+
live = sum(a for a, _ in pool.grants.values())
153+
spent = sum(pool.consumed.get(jti, 0) for jti in pool.grants)
154+
held = sum(a for _, a, _ in pool.reservations.values())
155+
return max(0, live - spent - held)
156+
157+
# ── public interface (the BudgetMeter contract) ──────────────────────────
158+
159+
async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
160+
exp: float) -> None:
161+
"""Register a token's envelope in the principal's pool (idempotent per
162+
``jti``). Raises :class:`UnitMismatch` if the pool already runs in a
163+
different unit — one envelope, one unit, no FX at the meter."""
164+
async with self._lock:
165+
now = time.monotonic()
166+
wall_delta = exp - time.time()
167+
pool = self._purge(key, now)
168+
if pool is None:
169+
pool = self._pools.setdefault(
170+
key, _Pool(unit=claim.unit, decimals=claim.decimals))
171+
if (pool.unit, pool.decimals) != (claim.unit, claim.decimals):
172+
raise UnitMismatch(
173+
f"pool runs in {pool.unit}/{pool.decimals}, "
174+
f"grant is {claim.unit}/{claim.decimals}")
175+
pool.last_activity = now
176+
if jti not in pool.grants and wall_delta > 0:
177+
pool.grants[jti] = (claim.amount, now + wall_delta)
178+
179+
async def reserve(self, key: MeterKey, jti: str,
180+
max_cost: int) -> Reservation | InsufficientBudget:
181+
async with self._lock:
182+
now = time.monotonic()
183+
pool = self._purge(key, now)
184+
if pool is None or jti not in pool.grants:
185+
return InsufficientBudget(remaining=0, exhausted=True)
186+
remaining = self._remaining(pool)
187+
if max_cost > remaining:
188+
return InsufficientBudget(remaining=remaining,
189+
exhausted=remaining == 0)
190+
rid = next(self._rids)
191+
pool.reservations[rid] = (jti, max_cost, now + self._reservation_ttl)
192+
pool.last_activity = now
193+
return Reservation(rid=rid, key=key, jti=jti, amount=max_cost)
194+
195+
async def commit(self, res: Reservation, actual: int) -> int:
196+
"""Commit the actual cost (clamped to the reserved amount — reservations
197+
are never revised upward) and return the pool's remaining balance."""
198+
async with self._lock:
199+
now = time.monotonic()
200+
pool = self._purge(res.key, now)
201+
if pool is None:
202+
return 0
203+
held = pool.reservations.pop(res.rid, None)
204+
cost = min(max(actual, 0), held[1] if held else res.amount)
205+
pool.consumed[res.jti] = pool.consumed.get(res.jti, 0) + cost
206+
pool.last_activity = now
207+
return self._remaining(pool)
208+
209+
async def release(self, res: Reservation) -> int:
210+
async with self._lock:
211+
pool = self._purge(res.key, time.monotonic())
212+
if pool is None:
213+
return 0
214+
pool.reservations.pop(res.rid, None)
215+
return self._remaining(pool)
216+
217+
async def remaining(self, key: MeterKey) -> int:
218+
async with self._lock:
219+
pool = self._purge(key, time.monotonic())
220+
return 0 if pool is None else self._remaining(pool)
221+
222+
async def consumed_records(self, key: MeterKey) -> list[dict[str, Any]]:
223+
"""Per-token consumption for the resource token's ``budget_consumed``
224+
claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
225+
PS deduplicates by ``jti``, so reporting the same record twice is safe."""
226+
async with self._lock:
227+
pool = self._purge(key, time.monotonic())
228+
if pool is None:
229+
return []
230+
return [
231+
{"jti": jti, "consumed": total}
232+
for jti, total in sorted(pool.consumed.items())
233+
if total > 0
234+
]

src/regent_httpsig/config.py

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

33
from __future__ import annotations
44

5+
from collections.abc import Mapping
56
from dataclasses import dataclass, field
67

78
__all__ = ["HttpsigConfig"]
@@ -38,3 +39,9 @@ class HttpsigConfig:
3839
# AAuth person tokens — their `aud` must name this resource. None disables
3940
# the person-token path entirely.
4041
resource_url: str | None = None
42+
# AAuth auth tokens (typ "aa-auth+jwt" — the carrier of budget envelopes):
43+
# issuer → JWKS URL for each Person Server this resource accepts auth tokens
44+
# from. A resource has an established relationship with its PS, so the key
45+
# location is pinned by configuration rather than discovered open-world.
46+
# Empty (the default) disables the auth-token path entirely.
47+
trusted_ps: Mapping[str, str] = field(default_factory=dict)

0 commit comments

Comments
 (0)