Skip to content

Commit d3b7b52

Browse files
committed
Scope refusal-time consumption records to the presenting agent's jkt
Per Dick Hardt's design note: the budget_consumed list in a refusal's resource token is limited to tokens bound to the presenter's cnf key, so one agent never learns about a sibling's spending (and gains no extra figures to infer the ceiling from). The meter records each jti's jkt at observe_grant; consumed_records(key, jkt=...) filters; the middleware passes the verified signature's keyid. Unscoped access remains for the PS-side/audit view. Consequence documented: an abandoned agent's records are never carried home by siblings — the conservative PS rule (unreported expired allocation = fully consumed) is the normative backstop.
1 parent 6f97330 commit d3b7b52

3 files changed

Lines changed: 81 additions & 10 deletions

File tree

src/regent_httpsig/budget.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class _Pool:
104104
decimals: int
105105
grants: dict[str, tuple[int, float]] = field(default_factory=dict) # jti -> (amount, exp)
106106
consumed: dict[str, int] = field(default_factory=dict) # jti -> total committed
107+
jkt_of: dict[str, str] = field(default_factory=dict) # jti -> presenting key thumbprint
107108
reservations: dict[int, tuple[str, int, float]] = field(default_factory=dict)
108109
last_activity: float = 0.0
109110

@@ -157,10 +158,13 @@ def _remaining(pool: _Pool) -> int:
157158
# ── public interface (the BudgetMeter contract) ──────────────────────────
158159

159160
async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
160-
exp: float) -> None:
161+
exp: float, jkt: str = "") -> None:
161162
"""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."""
163+
``jti``). ``jkt`` is the RFC 7638 thumbprint of the token's ``cnf`` key —
164+
recorded so consumption records can be scoped to the presenting agent
165+
(one agent must not learn about its siblings). Raises
166+
:class:`UnitMismatch` if the pool already runs in a different unit —
167+
one envelope, one unit, no FX at the meter."""
164168
async with self._lock:
165169
now = time.monotonic()
166170
wall_delta = exp - time.time()
@@ -173,6 +177,8 @@ async def observe_grant(self, key: MeterKey, jti: str, claim: BudgetClaim,
173177
f"pool runs in {pool.unit}/{pool.decimals}, "
174178
f"grant is {claim.unit}/{claim.decimals}")
175179
pool.last_activity = now
180+
if jkt:
181+
pool.jkt_of.setdefault(jti, jkt)
176182
if jti not in pool.grants and wall_delta > 0:
177183
pool.grants[jti] = (claim.amount, now + wall_delta)
178184

@@ -219,16 +225,24 @@ async def remaining(self, key: MeterKey) -> int:
219225
pool = self._purge(key, time.monotonic())
220226
return 0 if pool is None else self._remaining(pool)
221227

222-
async def consumed_records(self, key: MeterKey) -> list[dict[str, Any]]:
228+
async def consumed_records(self, key: MeterKey,
229+
jkt: str | None = None) -> list[dict[str, Any]]:
223230
"""Per-token consumption for the resource token's ``budget_consumed``
224231
claim: ``[{"jti": ..., "consumed": ...}, ...]``. Non-destructive — the
225-
PS deduplicates by ``jti``, so reporting the same record twice is safe."""
232+
PS deduplicates by ``jti``, so reporting the same record twice is safe.
233+
234+
When ``jkt`` is given, records are scoped to tokens bound to that key:
235+
the agent carrying the resource token sees only its OWN spending, never
236+
its siblings' (privacy between a principal's agents, and no extra
237+
figures to infer the ceiling from). Consequence: an abandoned agent's
238+
records are never carried home by siblings — the PS-side conservative
239+
rule (unreported expired allocation = fully consumed) is the backstop."""
226240
async with self._lock:
227241
pool = self._purge(key, time.monotonic())
228242
if pool is None:
229243
return []
230244
return [
231245
{"jti": jti, "consumed": total}
232246
for jti, total in sorted(pool.consumed.items())
233-
if total > 0
247+
if total > 0 and (jkt is None or pool.jkt_of.get(jti) == jkt)
234248
]

src/regent_httpsig/fastapi.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,12 @@ async def dispatch(
207207
str(sig.claims.get("aud", "")),
208208
)
209209
try:
210+
# sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
211+
# recorded so refusal-time consumption records are scoped to the
212+
# presenting agent and never disclose its siblings' spending.
210213
await self._meter.observe_grant(key, jti, envelope,
211-
float(sig.claims.get("exp", 0)))
214+
float(sig.claims.get("exp", 0)),
215+
jkt=sig.keyid)
212216
except UnitMismatch as exc:
213217
logger.warning("budget unit mismatch for %s: %s", key, exc)
214218
return self._refusal(reason="insufficient-budget", envelope=envelope,
@@ -218,7 +222,8 @@ async def dispatch(
218222
if isinstance(outcome, InsufficientBudget):
219223
reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
220224
return await self._refusal_with_token(
221-
reason=reason, envelope=envelope, remaining=outcome.remaining, key=key
225+
reason=reason, envelope=envelope, remaining=outcome.remaining,
226+
key=key, jkt=sig.keyid,
222227
)
223228

224229
reservation: Reservation = outcome
@@ -258,12 +263,12 @@ async def _verified(self, request: Request) -> VerifiedSignature | None:
258263

259264
async def _refusal_with_token(
260265
self, *, reason: str, envelope: BudgetClaim,
261-
remaining: int, key: MeterKey,
266+
remaining: int, key: MeterKey, jkt: str | None = None,
262267
) -> Response:
263268
token: str | None = None
264269
if self._resource_token is not None:
265270
try:
266-
records = await self._meter.consumed_records(key)
271+
records = await self._meter.consumed_records(key, jkt=jkt)
267272
token = await _maybe_await(self._resource_token(key, records))
268273
except Exception: # noqa: BLE001 — refusal must not fail on the extras
269274
logger.warning("resource_token_provider failed", exc_info=True)

tests/test_budget.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,58 @@ async def test_meter_commit_clamps_to_reservation() -> None:
175175
assert await meter.commit(res, 999999) == 700 # clamped to the 300 hold
176176

177177

178+
async def test_consumed_records_scoped_to_presenting_jkt() -> None:
179+
"""Two agents of one principal share the (iss, sub, aud) pool, but each
180+
sees only ITS OWN consumption records — never its siblings' (privacy +
181+
no extra figures to infer the ceiling from)."""
182+
meter = InMemoryMeter()
183+
now = time.time()
184+
await meter.observe_grant(KEY, "jti-a", _claim(500), now + 60, jkt="jkt-agent-A")
185+
await meter.observe_grant(KEY, "jti-b", _claim(500), now + 60, jkt="jkt-agent-B")
186+
for jti, cost in (("jti-a", 100), ("jti-b", 250)):
187+
res = await meter.reserve(KEY, jti, cost)
188+
assert isinstance(res, Reservation)
189+
await meter.commit(res, cost)
190+
191+
assert await meter.consumed_records(KEY, jkt="jkt-agent-A") == [
192+
{"jti": "jti-a", "consumed": 100}
193+
]
194+
assert await meter.consumed_records(KEY, jkt="jkt-agent-B") == [
195+
{"jti": "jti-b", "consumed": 250}
196+
]
197+
# Unscoped (PS-side / audit view) still returns the whole pool.
198+
assert len(await meter.consumed_records(KEY)) == 2
199+
200+
201+
async def test_refusal_records_scoped_to_presenter(
202+
monkeypatch: pytest.MonkeyPatch,
203+
) -> None:
204+
"""The resource token embedded in a refusal carries only the presenting
205+
agent's records: sibling B's spend must not ride home with agent A."""
206+
ps_priv, ps_jwk = _ps_pair()
207+
agent_a = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
208+
agent_b = EgressSigner(seed=generate_seed(), signature_agent=PS_ISS)
209+
token_a = _auth_token(ps_priv, agent_a, amount=400, jti="at-A")
210+
token_b = _auth_token(ps_priv, agent_b, amount=400, jti="at-B")
211+
provider_records: list[Any] = []
212+
213+
def provider(key: Any, records: Any) -> str:
214+
provider_records.append(records)
215+
return "resource.token"
216+
217+
app = _app(_verifier(ps_jwk, monkeypatch), resource_token_provider=provider)
218+
219+
# A spends 300 (price of /v1/search), B spends 300 — pool now at 200.
220+
assert (await _post(app, "/v1/search",
221+
_signed_headers(agent_a, token_a, "/v1/search"))).status_code == 200
222+
assert (await _post(app, "/v1/search",
223+
_signed_headers(agent_b, token_b, "/v1/search"))).status_code == 200
224+
# B asks again: 300 > 200 remaining → refusal with records — B's only.
225+
r = await _post(app, "/v1/search", _signed_headers(agent_b, token_b, "/v1/search"))
226+
assert r.status_code == 401
227+
assert provider_records == [[{"jti": "at-B", "consumed": 300}]]
228+
229+
178230
async def test_meter_concurrent_reserves_never_oversell() -> None:
179231
meter = InMemoryMeter()
180232
await meter.observe_grant(KEY, "jti-1", _claim(1000), time.time() + 60)

0 commit comments

Comments
 (0)