Enforce PolicyRule.maxUsageCount live at sign time (needs a durable signing log) #28

Closed
opened 2026-06-19 13:29:51 +00:00 by padreug · 0 comments
Owner

Summary

PolicyRule.maxUsageCount is written and displayed but never enforced — a usage-capped policy signs forever. This is the third sibling of the materialization-drift family from #25 (alongside token expiry #24 and revoke). #27 closed expiry + revoke but deferred usage caps, because enforcing them correctly needs infrastructure that doesn't exist yet.

Why it couldn't ride along in #27

The Option D approach (per the #25 discussion and the lnbits/nostr_bunker prior art) is derive-don't-count: usage = COUNT(allowed signings for this KeyUser+method) evaluated live, rather than a mutable currentUsageCount that drifts. But there is no durable record of allowed signings to count:

  • Request rows are created only on the manual-approval path (authorize.ts:createRecord) and are auto-deleted after 60s (authorize.ts:79).
  • Auto-approved (policy-granted) signings — the whole point of a usage-capped policy — record nothing.
  • The Log model exists in the schema but is never written anywhere (grep prisma.log → no hits).

So a COUNT query today would count almost nothing.

What's already in place (groundwork from #27)

  • Request.keyUserId (FK) + @@index([keyUserId, method]) — added in the #27 schema migration precisely so usage can be counted per grant.

Proposed work

  1. Durably record each allowed signing. Decide the record: either (a) repurpose Request with a durable allowed=true row carrying keyUserId+method (and stop auto-deleting those), or (b) start writing the dormant Log model as a signing-usage log. Lean toward a dedicated, clearly-named usage/audit record with a retention story (these grow unbounded).
  2. Enforce in checkIfPubkeyAllowed step 4. When a matching PolicyRule has maxUsageCount != null, COUNT prior allowed signings for (keyUserId, method) and deny once >= maxUsageCount. Read-only count keeps the gatekeeper a predicate; the write happens at the permit call site (run.ts) when a request is allowed.
  3. Drop PolicyRule.currentUsageCount — the mutable counter is the drift-prone cache we're replacing.
  4. Decide semantics: lifetime total cap (current maxUsageCount shape) vs per-window. A per-day cap (lnbits/nostr_bunker style) would need a window column — out of scope unless we want it.
  5. Tests once the harness from the integration-test follow-up lands.

Notes

  • Retention/growth of the durable log is the main design question — a signing log on a busy key grows fast; needs pruning or aggregation.
  • Cross-refs: #25 (design), #24 (expiry, fixed in #27), #27 (the PR that deferred this), spirekeeper#22 (revoke sibling).
## Summary `PolicyRule.maxUsageCount` is written and displayed but **never enforced** — a usage-capped policy signs forever. This is the third sibling of the materialization-drift family from #25 (alongside token expiry #24 and revoke). #27 closed expiry + revoke but **deferred usage caps**, because enforcing them correctly needs infrastructure that doesn't exist yet. ## Why it couldn't ride along in #27 The Option D approach (per the #25 discussion and the lnbits/nostr_bunker prior art) is **derive-don't-count**: usage = `COUNT(allowed signings for this KeyUser+method)` evaluated live, rather than a mutable `currentUsageCount` that drifts. But there is **no durable record of allowed signings** to count: - `Request` rows are created **only on the manual-approval path** (`authorize.ts:createRecord`) and are **auto-deleted after 60s** (`authorize.ts:79`). - Auto-approved (policy-granted) signings — the whole point of a usage-capped policy — record **nothing**. - The `Log` model exists in the schema but is **never written** anywhere (`grep prisma.log` → no hits). So a `COUNT` query today would count almost nothing. ## What's already in place (groundwork from #27) - `Request.keyUserId` (FK) + `@@index([keyUserId, method])` — added in the #27 schema migration precisely so usage can be counted per grant. ## Proposed work 1. **Durably record each allowed signing.** Decide the record: either (a) repurpose `Request` with a durable `allowed=true` row carrying `keyUserId`+`method` (and stop auto-deleting those), or (b) start writing the dormant `Log` model as a signing-usage log. Lean toward a dedicated, clearly-named usage/audit record with a retention story (these grow unbounded). 2. **Enforce in `checkIfPubkeyAllowed` step 4.** When a matching `PolicyRule` has `maxUsageCount != null`, `COUNT` prior allowed signings for `(keyUserId, method)` and deny once `>= maxUsageCount`. Read-only count keeps the gatekeeper a predicate; the write happens at the permit call site (`run.ts`) when a request is allowed. 3. **Drop `PolicyRule.currentUsageCount`** — the mutable counter is the drift-prone cache we're replacing. 4. **Decide semantics:** lifetime total cap (current `maxUsageCount` shape) vs per-window. A per-day cap (lnbits/nostr_bunker style) would need a window column — out of scope unless we want it. 5. **Tests** once the harness from the integration-test follow-up lands. ## Notes - Retention/growth of the durable log is the main design question — a signing log on a busy key grows fast; needs pruning or aggregation. - Cross-refs: #25 (design), #24 (expiry, fixed in #27), #27 (the PR that deferred this), `spirekeeper#22` (revoke sibling).
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/nsecbunkerd#28
No description provided.