diff --git a/.gitignore b/.gitignore index f3a073a..a6bcada 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ config .env .turbo prisma +tests/.tmp diff --git a/package.json b/package.json index f1f8732..07fa987 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "scripts": { "build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client", "build:client": "tsup src/client.ts -d dist/client", - "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.test.ts", + "test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts", + "test:integration": "DATABASE_URL=\"file:./tests/.tmp/acl-int.db\" node -r ./tests/register-ts.cjs --test tests/acl.integration.test.ts", + "test:all": "npm run test && npm run test:integration", "prisma:generate": "npx prisma generate", "prisma:migrate": "npx prisma migrate deploy", "prisma:create": "npx prisma db push --preview-feature", diff --git a/prisma/migrations/20260620194331_usage_caps_signing_log/migration.sql b/prisma/migrations/20260620194331_usage_caps_signing_log/migration.sql new file mode 100644 index 0000000..49e5a77 --- /dev/null +++ b/prisma/migrations/20260620194331_usage_caps_signing_log/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 73f54df..fd77f2e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/daemon/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts index 7a66345..503dd17 100644 --- a/src/daemon/admin/commands/add_policy_rule.ts +++ b/src/daemon/admin/commands/add_policy_rule.ts @@ -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, } }); diff --git a/src/daemon/admin/commands/create_new_policy.ts b/src/daemon/admin/commands/create_new_policy.ts index 0a6a787..af4bfd4 100644 --- a/src/daemon/admin/commands/create_new_policy.ts +++ b/src/daemon/admin/commands/create_new_policy.ts @@ -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, } }); } diff --git a/src/daemon/admin/commands/update_policy_rule.ts b/src/daemon/admin/commands/update_policy_rule.ts new file mode 100644 index 0000000..9b66bcf --- /dev/null +++ b/src/daemon/admin/commands/update_policy_rule.ts @@ -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); +} diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index a918a66..1354685 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -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, }; }) }; diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index 1c7912d..b8f72fa 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk'; +import type { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk'; import prisma from '../../../db.js'; import { liveWhere } from './lifecycle.js'; @@ -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([ + '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 { + 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 }, + }); +} diff --git a/src/daemon/run.ts b/src/daemon/run.ts index 7eaa512..738dc77 100644 --- a/src/daemon/run.ts +++ b/src/daemon/run.ts @@ -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) { diff --git a/tests/acl.integration.test.ts b/tests/acl.integration.test.ts new file mode 100644 index 0000000..6907f70 --- /dev/null +++ b/tests/acl.integration.test.ts @@ -0,0 +1,313 @@ +// Integration tests for checkIfPubkeyAllowed against a real (throwaway) SQLite +// DB — the wiring that actually closes #24 (step-4 Token join filtered by +// liveWhere) which the pure lifecycle.test.ts cannot exercise. +// +// Run via `npm run test:integration`, which sets DATABASE_URL to a temp file +// and routes through tests/register-ts.cjs (ts-node + `.js`->`.ts` resolution). +// Requires the prisma engine env (PRISMA_QUERY_ENGINE_LIBRARY etc.) on PATH — +// present in CI/nix; in the devShell pending #30. See #29. +import { test, before, beforeEach, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import prisma from '../src/db'; +import { checkIfPubkeyAllowed, recordSigning } from '../src/daemon/lib/acl/index'; + +const KEY = 'test-key'; +const PUB = 'client-pubkey-1'; + +const past = () => new Date(Date.now() - 60_000); +const future = () => new Date(Date.now() + 60_000); +const signEvt = { kind: 1 } as any; + +// ---- seeding helpers ------------------------------------------------------- + +async function seedPolicy(method: string, kind: string | null): Promise { + const policy = await prisma.policy.create({ + data: { name: 'p', rules: { create: [{ method, kind: kind ?? undefined }] } }, + }); + return policy.id; +} + +async function seedKeyUser(opts: { revokedAt?: Date | null } = {}): Promise { + const ku = await prisma.keyUser.create({ + data: { keyName: KEY, userPubkey: PUB, revokedAt: opts.revokedAt ?? null }, + }); + return ku.id; +} + +async function seedToken( + keyUserId: number, + policyId: number, + opts: { expiresAt?: Date | null; revokedAt?: Date | null } = {}, +): Promise { + await prisma.token.create({ + data: { + keyName: KEY, + token: 't-' + Math.random().toString(36).slice(2), + clientName: 'c', + createdBy: 'admin', + keyUserId, + policyId, + redeemedAt: new Date(), + expiresAt: opts.expiresAt ?? null, + revokedAt: opts.revokedAt ?? null, + }, + }); +} + +async function seedCondition( + keyUserId: number, + opts: { method: string; kind?: string | null; allowed: boolean; expiresAt?: Date | null; revokedAt?: Date | null }, +): Promise { + await prisma.signingCondition.create({ + data: { + keyUserId, + method: opts.method, + kind: opts.kind ?? undefined, + allowed: opts.allowed, + expiresAt: opts.expiresAt ?? null, + revokedAt: opts.revokedAt ?? null, + }, + }); +} + +type RuleSeed = { method: string; kind: string | null; maxUsageCount?: number | null; windowSeconds?: number | null }; + +async function seedPolicyRules(rules: RuleSeed[]): Promise { + 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 { + 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(() => { + const url = process.env.DATABASE_URL || ''; + // Hard guard: these tests truncate tables. Never let that touch a real DB. + if (!url.includes('tests/.tmp/')) { + throw new Error( + `Refusing to run: DATABASE_URL must point at tests/.tmp/ (got "${url}"). Use 'npm run test:integration'.`, + ); + } + const dir = path.resolve(process.cwd(), 'tests/.tmp'); + fs.mkdirSync(dir, { recursive: true }); + fs.rmSync(path.resolve(dir, 'acl-int.db'), { force: true }); + execSync('npx prisma db push --skip-generate --accept-data-loss', { + stdio: 'pipe', + env: process.env, + }); +}); + +beforeEach(async () => { + // FK-safe truncation order. + await prisma.signingLog.deleteMany(); + await prisma.signingCondition.deleteMany(); + await prisma.request.deleteMany(); + await prisma.token.deleteMany(); + await prisma.policyRule.deleteMany(); + await prisma.policy.deleteMany(); + await prisma.keyUser.deleteMany(); +}); + +after(async () => { + await prisma.$disconnect(); +}); + +// ---- cases ----------------------------------------------------------------- + +test('live token + matching policy rule -> sign_event allowed', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, {}); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); +}); + +test('expired token -> sign_event denied [#24 regression guard]', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, { expiresAt: past() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); +}); + +test('revoked token -> sign_event denied', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, { revokedAt: past() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); +}); + +test('live token -> connect allowed (pairing)', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, {}); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), true); +}); + +test('expired token -> connect denied', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, { expiresAt: past() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'connect'), undefined); +}); + +test('KeyUser.revokedAt denies (false) and beats a live token', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser({ revokedAt: past() }); + await seedToken(ku, pid, {}); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false); +}); + +test('live SigningCondition grant -> allowed', async () => { + const ku = await seedKeyUser(); + await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); +}); + +test('expired SigningCondition grant -> ignored (falls through to undefined)', async () => { + const ku = await seedKeyUser(); + await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: past() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); +}); + +test('revoked SigningCondition grant -> ignored (falls through to undefined)', async () => { + const ku = await seedKeyUser(); + await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, revokedAt: past() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), undefined); +}); + +test('live SigningCondition deny beats a live token grant -> false', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, {}); + await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: false }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), false); +}); + +test('future-dated token + future-dated grant stay live', async () => { + const ku = await seedKeyUser(); + await seedCondition(ku, { method: 'sign_event', kind: '1', allowed: true, expiresAt: future() }); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', signEvt), true); +}); + +test('policy rule kind mismatch -> undefined', async () => { + const pid = await seedPolicy('sign_event', '1'); + const ku = await seedKeyUser(); + await seedToken(ku, pid, {}); + assert.equal(await checkIfPubkeyAllowed(KEY, PUB, 'sign_event', { kind: 2 } as any), undefined); +}); + +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 +}); diff --git a/tests/register-ts.cjs b/tests/register-ts.cjs new file mode 100644 index 0000000..ddd5ca7 --- /dev/null +++ b/tests/register-ts.cjs @@ -0,0 +1,32 @@ +// Test bootstrap: register ts-node (transpile-only) AND teach CommonJS to +// resolve the project's `.js`-extension, ESM-style import specifiers to their +// `.ts` sources. The app is written `import x from './y.js'` but compiled by +// tsup; under a plain CommonJS require there is no `y.js` on disk, only `y.ts`. +// This lets integration tests import the real app modules (acl, db, backend) +// without a build step or a bundler. Used by the `test:integration` script. +const Module = require('module'); + +require('ts-node').register({ transpileOnly: true }); + +const originalResolve = Module._resolveFilename; +Module._resolveFilename = function (request, parent, isMain, options) { + try { + return originalResolve.call(this, request, parent, isMain, options); + } catch (err) { + // Only retry relative `.js` specifiers as `.ts` — never touch package + // imports (e.g. `@prisma/client`) or anything that already resolved. + if ( + (request.startsWith('./') || request.startsWith('../')) && + request.endsWith('.js') + ) { + return originalResolve.call( + this, + request.slice(0, -3) + '.ts', + parent, + isMain, + options, + ); + } + throw err; + } +};