feat(acl): per-rule windowed usage caps enforced live at sign time (#28) #34
8 changed files with 236 additions and 23 deletions
feat(acl)(#28): per-rule windowed usage caps enforced live at sign time
Completes the lifecycle family from #25 — usage caps were the third sibling (after expiry #24 and revoke), written-but-never-enforced. Model: - PolicyRule gains windowSeconds; drops the never-enforced mutable currentUsageCount. A cap = (maxUsageCount, windowSeconds): at most N signings of this (method,kind) per rolling window. windowSeconds NULL = lifetime; maxUsageCount NULL = uncapped. - New SigningLog: durable append-only record of allowed signings — the source of truth caps count against (derive-don't-count; no counter to drift). Enforcement (checkIfPubkeyAllowed step 4): among the live token's matching rules, every capped rule must have remaining budget in its window (COUNT(SigningLog) < maxUsageCount), counted live. Stacked caps all bind — 20/hr AND 200/day enforced together. recordSigning() writes a SigningLog row from the permit callback when a consequential request (sign_event / encrypt / decrypt) is allowed. Retune live: new update_policy_rule admin RPC patches maxUsageCount/ windowSeconds/method/kind in place; takes effect next request, no re-pairing (a payoff of the #27 Option D design). get_policies now returns each rule's id + window_seconds so callers can target it. Retention/pruning of SigningLog is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit
6929f42115
|
|
@ -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");
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
58
src/daemon/admin/commands/update_policy_rule.ts
Normal file
58
src/daemon/admin/commands/update_policy_rule.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
})
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue