|
| 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 | + ] |
0 commit comments