Compare commits
No commits in common. "59295318f8c589a793145f5afb7b54cccf45c880" and "992c6a8d4a6d80247c3e1638f8fb35e5f847933a" have entirely different histories.
59295318f8
...
992c6a8d4a
12 changed files with 25 additions and 586 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,4 +9,3 @@ config
|
||||||
.env
|
.env
|
||||||
.turbo
|
.turbo
|
||||||
prisma
|
prisma
|
||||||
tests/.tmp
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts; tsup src/daemon/index.ts -d dist/daemon; tsup src/client.ts -d dist/client",
|
"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",
|
"build:client": "tsup src/client.ts -d dist/client",
|
||||||
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/lifecycle.test.ts",
|
"test": "TS_NODE_TRANSPILE_ONLY=1 node -r ts-node/register --test tests/*.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:generate": "npx prisma generate",
|
||||||
"prisma:migrate": "npx prisma migrate deploy",
|
"prisma:migrate": "npx prisma migrate deploy",
|
||||||
"prisma:create": "npx prisma db push --preview-feature",
|
"prisma:create": "npx prisma db push --preview-feature",
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
/*
|
|
||||||
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,7 +40,6 @@ model KeyUser {
|
||||||
signingConditions SigningCondition[]
|
signingConditions SigningCondition[]
|
||||||
Token Token[]
|
Token Token[]
|
||||||
requests Request[]
|
requests Request[]
|
||||||
signingLogs SigningLog[]
|
|
||||||
|
|
||||||
@@unique([keyName, userPubkey], name: "unique_key_user")
|
@@unique([keyName, userPubkey], name: "unique_key_user")
|
||||||
}
|
}
|
||||||
|
|
@ -110,34 +109,13 @@ model Policy {
|
||||||
}
|
}
|
||||||
|
|
||||||
model PolicyRule {
|
model PolicyRule {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
method String
|
method String
|
||||||
kind String?
|
kind String?
|
||||||
// Usage cap (aiolabs/nsecbunkerd#28): allow at most `maxUsageCount`
|
maxUsageCount Int?
|
||||||
// signings of this (method, kind) per rolling `windowSeconds`, counted
|
currentUsageCount Int?
|
||||||
// live from SigningLog. `maxUsageCount` NULL = uncapped; `windowSeconds`
|
policyId Int?
|
||||||
// NULL = lifetime window (count all-time). Replaces the never-enforced
|
Policy Policy? @relation(fields: [policyId], references: [id])
|
||||||
// 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 {
|
model Token {
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,7 @@ import prisma from "../../../db.js";
|
||||||
* { policyId: number,
|
* { policyId: number,
|
||||||
* rule: { method: string,
|
* rule: { method: string,
|
||||||
* kind?: number | "all" | null,
|
* 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
|
* `kind` is stored as a string for parity with create_new_policy.ts's
|
||||||
* `rule.kind.toString()` storage and the override-layer convention. The
|
* `rule.kind.toString()` storage and the override-layer convention. The
|
||||||
|
|
@ -44,7 +39,7 @@ export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRe
|
||||||
method: rule.method,
|
method: rule.method,
|
||||||
kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null,
|
kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null,
|
||||||
maxUsageCount: rule.maxUsageCount,
|
maxUsageCount: rule.maxUsageCount,
|
||||||
windowSeconds: rule.windowSeconds,
|
currentUsageCount: 0,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default async function createNewPolicy(admin: AdminInterface, req: NDKRpc
|
||||||
kind: rule.kind.toString(),
|
kind: rule.kind.toString(),
|
||||||
method: rule.method,
|
method: rule.method,
|
||||||
maxUsageCount: rule.use_count,
|
maxUsageCount: rule.use_count,
|
||||||
windowSeconds: rule.window_seconds,
|
currentUsageCount: 0,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
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,7 +16,6 @@ import revokeUser from './commands/revoke_user';
|
||||||
import addPolicyRule from './commands/add_policy_rule';
|
import addPolicyRule from './commands/add_policy_rule';
|
||||||
import removePolicyRule from './commands/remove_policy_rule';
|
import removePolicyRule from './commands/remove_policy_rule';
|
||||||
import updatePolicy from './commands/update_policy';
|
import updatePolicy from './commands/update_policy';
|
||||||
import updatePolicyRule from './commands/update_policy_rule';
|
|
||||||
import addSigningCondition from './commands/add_signing_condition';
|
import addSigningCondition from './commands/add_signing_condition';
|
||||||
import removeSigningCondition from './commands/remove_signing_condition';
|
import removeSigningCondition from './commands/remove_signing_condition';
|
||||||
import revokeToken from './commands/revoke_token';
|
import revokeToken from './commands/revoke_token';
|
||||||
|
|
@ -223,7 +222,6 @@ class AdminInterface {
|
||||||
case 'add_policy_rule': await addPolicyRule(this, req); break;
|
case 'add_policy_rule': await addPolicyRule(this, req); break;
|
||||||
case 'remove_policy_rule': await removePolicyRule(this, req); break;
|
case 'remove_policy_rule': await removePolicyRule(this, req); break;
|
||||||
case 'update_policy': await updatePolicy(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 'add_signing_condition': await addSigningCondition(this, req); break;
|
||||||
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
|
case 'remove_signing_condition': await removeSigningCondition(this, req); break;
|
||||||
case 'revoke_token': await revokeToken(this, req); break;
|
case 'revoke_token': await revokeToken(this, req); break;
|
||||||
|
|
@ -330,11 +328,10 @@ class AdminInterface {
|
||||||
expires_at: p.expiresAt,
|
expires_at: p.expiresAt,
|
||||||
rules: p.rules.map((r) => {
|
rules: p.rules.map((r) => {
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
|
||||||
method: r.method,
|
method: r.method,
|
||||||
kind: r.kind,
|
kind: r.kind,
|
||||||
max_usage_count: r.maxUsageCount,
|
max_usage_count: r.maxUsageCount,
|
||||||
window_seconds: r.windowSeconds,
|
current_usage_count: r.currentUsageCount,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
|
import { NostrEvent, NIP46Method } from '@nostr-dev-kit/ndk';
|
||||||
import prisma from '../../../db.js';
|
import prisma from '../../../db.js';
|
||||||
import { liveWhere } from './lifecycle.js';
|
import { liveWhere } from './lifecycle.js';
|
||||||
|
|
||||||
|
|
@ -7,16 +7,6 @@ import { liveWhere } from './lifecycle.js';
|
||||||
// lives in ./lifecycle.ts so it can be unit-tested without a database.
|
// lives in ./lifecycle.ts so it can be unit-tested without a database.
|
||||||
export { grantIsLive } from './lifecycle.js';
|
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):
|
* Layered authorization check. Order matters (denials beat grants):
|
||||||
*
|
*
|
||||||
|
|
@ -120,47 +110,22 @@ export async function checkIfPubkeyAllowed(
|
||||||
kindMatchers.push({ kind: payloadKindString });
|
kindMatchers.push({ kind: payloadKindString });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find live tokens bound to this KeyUser whose policy has at least one
|
const policyAllowance = await prisma.token.findFirst({
|
||||||
// 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: {
|
where: {
|
||||||
keyUserId: keyUser.id,
|
keyUserId: keyUser.id,
|
||||||
...live,
|
...live,
|
||||||
policy: { rules: { some: { method, OR: kindMatchers } } },
|
policy: {
|
||||||
|
rules: {
|
||||||
|
some: {
|
||||||
|
method,
|
||||||
|
OR: kindMatchers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: { policy: { include: { rules: true } } },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const matchingRules = liveTokens
|
if (policyAllowance) {
|
||||||
.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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -251,47 +216,3 @@ export async function allowAllRequestsFromKey(
|
||||||
console.log('allowAllRequestsFromKey', e);
|
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 NDK, { NDKPrivateKeySigner, Nip46PermitCallback, Nip46PermitCallbackParams } from '@nostr-dev-kit/ndk';
|
||||||
import { nip19, utils as nostrUtils } from 'nostr-tools';
|
import { nip19, utils as nostrUtils } from 'nostr-tools';
|
||||||
import { Backend } from './backend/index.js';
|
import { Backend } from './backend/index.js';
|
||||||
import { checkIfPubkeyAllowed, recordSigning } from './lib/acl/index.js';
|
import { checkIfPubkeyAllowed } from './lib/acl/index.js';
|
||||||
import AdminInterface from './admin/index.js';
|
import AdminInterface from './admin/index.js';
|
||||||
import { IConfig } from '../config/index.js';
|
import { IConfig } from '../config/index.js';
|
||||||
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
|
import { NDKRpcRequest } from '@nostr-dev-kit/ndk';
|
||||||
|
|
@ -109,12 +109,6 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
|
||||||
|
|
||||||
if (keyAllowed === true || keyAllowed === false) {
|
if (keyAllowed === true || keyAllowed === false) {
|
||||||
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
|
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;
|
return keyAllowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,11 +121,7 @@ function signingAuthorizationCallback(keyName: string, adminInterface: AdminInte
|
||||||
method,
|
method,
|
||||||
payload
|
payload
|
||||||
)
|
)
|
||||||
.then(async () => {
|
.then(() => resolve(true))
|
||||||
await recordSigning(keyName, remotePubkey, method, payload)
|
|
||||||
.catch((e) => console.log('recordSigning error:', e));
|
|
||||||
resolve(true);
|
|
||||||
})
|
|
||||||
.catch(() => resolve(false));
|
.catch(() => resolve(false));
|
||||||
});
|
});
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
|
|
||||||
|
|
@ -1,313 +0,0 @@
|
||||||
// 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<number> {
|
|
||||||
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<number> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<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(() => {
|
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue