feat(acl): per-rule windowed usage caps enforced live at sign time (#28) #34

Merged
padreug merged 2 commits from issue-28-usage-caps into dev 2026-06-21 10:34:23 +00:00
9 changed files with 355 additions and 24 deletions

View file

@ -0,0 +1,36 @@
/*
Warnings:
- You are about to drop the column `currentUsageCount` on the `PolicyRule` table. All the data in the column will be lost.
*/
-- CreateTable
CREATE TABLE "SigningLog" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"keyUserId" INTEGER NOT NULL,
"method" TEXT NOT NULL,
"kind" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SigningLog_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_PolicyRule" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"method" TEXT NOT NULL,
"kind" TEXT,
"maxUsageCount" INTEGER,
"windowSeconds" INTEGER,
"policyId" INTEGER,
CONSTRAINT "PolicyRule_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "Policy" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_PolicyRule" ("id", "kind", "maxUsageCount", "method", "policyId") SELECT "id", "kind", "maxUsageCount", "method", "policyId" FROM "PolicyRule";
DROP TABLE "PolicyRule";
ALTER TABLE "new_PolicyRule" RENAME TO "PolicyRule";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "SigningLog_keyUserId_method_createdAt_idx" ON "SigningLog"("keyUserId", "method", "createdAt");

View file

@ -40,6 +40,7 @@ model KeyUser {
signingConditions SigningCondition[]
Token Token[]
requests Request[]
signingLogs SigningLog[]
@@unique([keyName, userPubkey], name: "unique_key_user")
}
@ -109,13 +110,34 @@ model Policy {
}
model PolicyRule {
id Int @id @default(autoincrement())
method String
kind String?
maxUsageCount Int?
currentUsageCount Int?
policyId Int?
Policy Policy? @relation(fields: [policyId], references: [id])
id Int @id @default(autoincrement())
method String
kind String?
// Usage cap (aiolabs/nsecbunkerd#28): allow at most `maxUsageCount`
// signings of this (method, kind) per rolling `windowSeconds`, counted
// live from SigningLog. `maxUsageCount` NULL = uncapped; `windowSeconds`
// NULL = lifetime window (count all-time). Replaces the never-enforced
// mutable `currentUsageCount` (derive-don't-count). Multiple capped rules
// for one request all bind (stacked caps, e.g. 20/hr AND 200/day).
maxUsageCount Int?
windowSeconds Int?
policyId Int?
Policy Policy? @relation(fields: [policyId], references: [id])
}
// Durable, append-only record of ALLOWED signings — the source of truth
// usage caps count against (aiolabs/nsecbunkerd#28). One row per allowed
// consequential request (sign_event + encrypt/decrypt; connect/ping/
// get_public_key are never recorded). Retention/pruning is a follow-up.
model SigningLog {
id Int @id @default(autoincrement())
keyUserId Int
method String
kind String?
createdAt DateTime @default(now())
KeyUser KeyUser @relation(fields: [keyUserId], references: [id])
@@index([keyUserId, method, createdAt])
}
model Token {

View file

@ -12,7 +12,12 @@ import prisma from "../../../db.js";
* { policyId: number,
* rule: { method: string,
* kind?: number | "all" | null,
* maxUsageCount?: number } }
* maxUsageCount?: number,
* windowSeconds?: number } }
*
* `maxUsageCount` + `windowSeconds` form a usage cap (#28): at most
* `maxUsageCount` signings of this (method, kind) per rolling
* `windowSeconds`; `windowSeconds` absent/null = lifetime window.
*
* `kind` is stored as a string for parity with create_new_policy.ts's
* `rule.kind.toString()` storage and the override-layer convention. The
@ -39,7 +44,7 @@ export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRe
method: rule.method,
kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null,
maxUsageCount: rule.maxUsageCount,
currentUsageCount: 0,
windowSeconds: rule.windowSeconds,
}
});

View file

@ -24,7 +24,7 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
kind: rule.kind.toString(),
method: rule.method,
maxUsageCount: rule.use_count,
currentUsageCount: 0,
windowSeconds: rule.window_seconds,
}
});
}

View file

@ -0,0 +1,58 @@
import { NDKRpcRequest } from "@nostr-dev-kit/ndk";
import AdminInterface from "../index.js";
import { NIP46_ADMIN_RESPONSE_KIND } from "../kinds.js";
import prisma from "../../../db.js";
/**
* Update mutable fields on a single PolicyRule in place primarily to retune
* a usage cap (#28) without the remove+add dance. Under the live-policy model
* the change takes effect at the next sign-time check for every KeyUser bound
* to the policy; no re-pairing, no migration. So switching e.g. 200/day to
* 20/hour is `{ maxUsageCount: 20, windowSeconds: 3600 }`.
*
* Param shape (JSON-stringified):
* { ruleId: number,
* patch: { method?: string,
* kind?: number | "all" | null,
* maxUsageCount?: number | null,
* windowSeconds?: number | null } }
*
* A field absent from `patch` is left alone; `null` clears it
* (`maxUsageCount: null` = uncapped, `windowSeconds: null` = lifetime window).
*
* Tightening a cap takes effect immediately a client already over the new
* limit within the window is denied until its trailing count falls below it.
*/
export default async function updatePolicyRule(admin: AdminInterface, req: NDKRpcRequest) {
const [ _payload ] = req.params as [ string ];
if (!_payload) throw new Error("Invalid params");
const payload = JSON.parse(_payload);
const { ruleId, patch } = payload;
if (typeof ruleId !== "number" || !patch || typeof patch !== "object") {
throw new Error("Invalid params");
}
const data: {
method?: string;
kind?: string | null;
maxUsageCount?: number | null;
windowSeconds?: number | null;
} = {};
if (patch.method !== undefined) data.method = patch.method;
if (patch.kind !== undefined) {
data.kind = patch.kind === null ? null : patch.kind.toString();
}
if (patch.maxUsageCount !== undefined) data.maxUsageCount = patch.maxUsageCount;
if (patch.windowSeconds !== undefined) data.windowSeconds = patch.windowSeconds;
if (Object.keys(data).length === 0) throw new Error("Empty patch");
await prisma.policyRule.update({ where: { id: ruleId }, data });
const result = JSON.stringify(["ok"]);
return admin.rpc.sendResponse(req.id, req.pubkey, result, NIP46_ADMIN_RESPONSE_KIND);
}

View file

@ -16,6 +16,7 @@ import revokeUser from './commands/revoke_user';
import addPolicyRule from './commands/add_policy_rule';
import removePolicyRule from './commands/remove_policy_rule';
import updatePolicy from './commands/update_policy';
import updatePolicyRule from './commands/update_policy_rule';
import addSigningCondition from './commands/add_signing_condition';
import removeSigningCondition from './commands/remove_signing_condition';
import revokeToken from './commands/revoke_token';
@ -222,6 +223,7 @@ class AdminInterface {
case 'add_policy_rule': await addPolicyRule(this, req); break;
case 'remove_policy_rule': await removePolicyRule(this, req); break;
case 'update_policy': await updatePolicy(this, req); break;
case 'update_policy_rule': await updatePolicyRule(this, req); break;
case 'add_signing_condition': await addSigningCondition(this, req); break;
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
case 'revoke_token': await revokeToken(this, req); break;
@ -328,10 +330,11 @@ class AdminInterface {
expires_at: p.expiresAt,
rules: p.rules.map((r) => {
return {
id: r.id,
method: r.method,
kind: r.kind,
max_usage_count: r.maxUsageCount,
current_usage_count: r.currentUsageCount,
window_seconds: r.windowSeconds,
};
})
};

View file

@ -7,6 +7,16 @@ import { liveWhere } from './lifecycle.js';
// lives in ./lifecycle.ts so it can be unit-tested without a database.
export { grantIsLive } from './lifecycle.js';
/**
* Does a PolicyRule's stored `kind` match this request's kind? Mirrors the
* `kindMatchers` used in the token query: a NULL or 'all' rule matches any
* kind; otherwise it must equal the (stringified) payload kind.
*/
function ruleKindMatches(ruleKind: string | null, payloadKindString?: string): boolean {
if (ruleKind === null || ruleKind === 'all') return true;
return payloadKindString !== undefined && ruleKind === payloadKindString;
}
/**
* Layered authorization check. Order matters (denials beat grants):
*
@ -110,22 +120,47 @@ export async function checkIfPubkeyAllowed(
kindMatchers.push({ kind: payloadKindString });
}
const policyAllowance = await prisma.token.findFirst({
// Find live tokens bound to this KeyUser whose policy has at least one
// rule matching (method, kind), and pull the rules so usage caps can be
// enforced live off the SigningLog (#28).
const liveTokens = await prisma.token.findMany({
where: {
keyUserId: keyUser.id,
...live,
policy: {
rules: {
some: {
method,
OR: kindMatchers,
},
},
},
policy: { rules: { some: { method, OR: kindMatchers } } },
},
include: { policy: { include: { rules: true } } },
});
if (policyAllowance) {
const matchingRules = liveTokens
.flatMap((t) => t.policy?.rules ?? [])
.filter((r) => r.method === method && ruleKindMatches(r.kind, payloadKindString));
if (matchingRules.length > 0) {
// Stacked caps (#28): every matching rule that carries a cap must
// have remaining budget in its window — so e.g. 20/hr AND 200/day
// both bind. An uncapped matching rule grants access with no limit.
for (const rule of matchingRules) {
if (rule.maxUsageCount == null) continue;
// null window = lifetime: count all-time, no createdAt floor.
const since = rule.windowSeconds != null
? new Date(now.getTime() - rule.windowSeconds * 1000)
: undefined;
const used = await prisma.signingLog.count({
where: {
keyUserId: keyUser.id,
method,
// A kind-specific rule caps only that kind; an
// 'all'/NULL rule caps every kind of the method.
...(rule.kind === null || rule.kind === 'all' ? {} : { kind: rule.kind }),
...(since ? { createdAt: { gt: since } } : {}),
},
});
if (used >= rule.maxUsageCount) {
// Cap exhausted — deny (step 5 / caller may still prompt).
return undefined;
}
}
return true;
}
}
@ -216,3 +251,47 @@ export async function allowAllRequestsFromKey(
console.log('allowAllRequestsFromKey', e);
}
}
/**
* Consequential methods whose allowed requests are recorded in SigningLog
* the set usage caps (#28) can count against. `connect`/`ping`/
* `get_public_key` are never recorded (they're not signings and are never
* meaningfully capped).
*/
const RECORDED_METHODS = new Set<string>([
'sign_event',
'encrypt', 'decrypt',
'nip04_encrypt', 'nip04_decrypt',
'nip44_encrypt', 'nip44_decrypt',
]);
/**
* Append a durable SigningLog row for an ALLOWED consequential request the
* source of truth `checkIfPubkeyAllowed` counts usage against (#28, the
* derive-don't-count approach: no mutable counter to drift). Called from the
* permit callback after a request is granted. Best-effort: a failure here must
* not block a sign that was already authorized.
*/
export async function recordSigning(
keyName: string,
remotePubkey: string,
method: IMethod,
payload?: string | NostrEvent,
): Promise<void> {
if (!RECORDED_METHODS.has(method)) return;
const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
select: { id: true },
});
if (!keyUser) return;
const kind =
method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined
? payload.kind.toString()
: null;
await prisma.signingLog.create({
data: { keyUserId: keyUser.id, method, kind },
});
}

View file

@ -1,7 +1,7 @@
import NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
import { nip19, utils as nostrUtils } from 'nostr-tools';
import { Backend } from './backend/index.js';
import { checkIfPubkeyAllowed } from './lib/acl/index.js';
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
import AdminInterface from './admin/index.js';
import { IConfig } from '../config/index.js';
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
@ -109,6 +109,12 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
if (keyAllowed === true || keyAllowed === false) {
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
if (keyAllowed === true) {
// Record the allowed signing so usage caps can count it (#28).
// Best-effort: never block an already-authorized sign.
await recordSigning(keyName, remotePubkey, method, payload)
.catch((e) => console.log('recordSigning error:', e));
}
return keyAllowed;
}
@ -121,7 +127,11 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
method,
payload
)
.then(() => resolve(true))
.then(async () => {
await recordSigning(keyName, remotePubkey, method, payload)
.catch((e) => console.log('recordSigning error:', e));
resolve(true);
})
.catch(() => resolve(false));
});
} catch(e) {

View file

@ -13,7 +13,7 @@ import fs from 'node:fs';
import path from 'node:path';
import prisma from '../src/db';
import { checkIfPubkeyAllowed } from '../src/daemon/lib/acl/index';
import { checkIfPubkeyAllowed, recordSigning } from '../src/daemon/lib/acl/index';
const KEY = 'test-key';
const PUB = 'client-pubkey-1';
@ -74,6 +74,38 @@ async function seedCondition(
});
}
type RuleSeed = { method: string; kind: string | null; maxUsageCount?: number | null; windowSeconds?: number | null };
async function seedPolicyRules(rules: RuleSeed[]): Promise<number> {
const policy = await prisma.policy.create({
data: {
name: 'p',
rules: {
create: rules.map((r) => ({
method: r.method,
kind: r.kind ?? undefined,
maxUsageCount: r.maxUsageCount ?? null,
windowSeconds: r.windowSeconds ?? null,
})),
},
},
});
return policy.id;
}
async function seedSigningLog(
keyUserId: number,
method: string,
kind: string | null,
count: number,
ageMs = 0,
): Promise<void> {
const createdAt = new Date(Date.now() - ageMs);
for (let i = 0; i < count; i++) {
await prisma.signingLog.create({ data: { keyUserId, method, kind, createdAt } });
}
}
// ---- lifecycle -------------------------------------------------------------
before(() => {
@ -95,6 +127,7 @@ before(() => {
beforeEach(async () => {
// FK-safe truncation order.
await prisma.signingLog.deleteMany();
await prisma.signingCondition.deleteMany();
await prisma.request.deleteMany();
await prisma.token.deleteMany();
@ -193,3 +226,88 @@ test('policy rule kind mismatch -> undefined', async () => {
test('no KeyUser -> undefined', async () => {
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
// ---- usage caps (#28) ------------------------------------------------------
test('usage cap: under the limit -> allowed', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 2); // 2 < 3
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('usage cap: at the limit -> denied', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 3, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 3); // 3 >= 3
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('usage cap: signings outside the window do not count', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 5, 2 * 3600 * 1000); // 5, aged 2h (outside 1h)
await seedSigningLog(ku, 'sign_event', '1', 1); // 1 recent
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // only 1 in window
});
test('uncapped rule -> allowed regardless of log volume', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: null }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 100);
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true);
});
test('lifetime cap (windowSeconds null) counts all-time', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: null }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 2, 10 * 24 * 3600 * 1000); // 2, aged 10 days
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // lifetime: 2 >= 2
});
test('kind-specific cap counts only that kind', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 2, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '2', 5); // kind 2 — irrelevant to a kind-1 cap
await seedSigningLog(ku, 'sign_event', '1', 1);
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // kind-1 count = 1 < 2
});
test('stacked caps: the hourly cap binds even when the daily is fine', async () => {
const pid = await seedPolicyRules([
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 20); // hits the 20/hr cap; well under 200/day
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('stacked caps: the daily cap binds even when the hourly is fine', async () => {
const pid = await seedPolicyRules([
{ method: 'sign_event', kind: '1', maxUsageCount: 20, windowSeconds: 3600 },
{ method: 'sign_event', kind: '1', maxUsageCount: 200, windowSeconds: 86400 },
]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
await seedSigningLog(ku, 'sign_event', '1', 15); // hourly: 15 < 20 OK
await seedSigningLog(ku, 'sign_event', '1', 190, 2 * 3600 * 1000); // +190 aged 2h: in day, not hour
// hourly = 15 (OK), daily = 205 >= 200 -> denied
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined);
});
test('recordSigning feeds the cap: record then re-check denies at the limit', async () => {
const pid = await seedPolicyRules([{ method: 'sign_event', kind: '1', maxUsageCount: 1, windowSeconds: 3600 }]);
const ku = await seedKeyUser();
await seedToken(ku, pid, {});
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); // 0 < 1
await recordSigning(KEY, PUB, 'sign_event', signEvt); // now 1 recorded
assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); // 1 >= 1
});