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>
This commit is contained in:
parent
0b9ffe8ca6
commit
6929f42115
8 changed files with 236 additions and 23 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue