diff --git a/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql b/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql deleted file mode 100644 index 4dbce97..0000000 --- a/prisma/migrations/20260530112308_add_token_revoked_at/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f0072ea..e8b5344 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -110,7 +110,6 @@ model Token { deletedAt DateTime? expiresAt DateTime? redeemedAt DateTime? - revokedAt DateTime? keyUserId Int? policyId Int? policy Policy? @relation(fields: [policyId], references: [id]) diff --git a/src/daemon/admin/commands/add_policy_rule.ts b/src/daemon/admin/commands/add_policy_rule.ts deleted file mode 100644 index 041fd1e..0000000 --- a/src/daemon/admin/commands/add_policy_rule.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Add a single PolicyRule to an existing Policy. Under the live-policy - * auth model (#11), this propagates to every KeyUser bound to the - * policy at the next sign-time check. - * - * Param shape (JSON-stringified): - * { policyId: number, - * rule: { method: string, - * kind?: number | "all" | null, - * maxUsageCount?: number } } - * - * `kind` is stored as a string for parity with create_new_policy.ts's - * `rule.kind.toString()` storage and the override-layer convention. The - * `'all'` literal is honored at sign-time as a wildcard across kinds. - */ -export default async function addPolicyRule(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { policyId, rule } = payload; - - if (typeof policyId !== "number" || !rule || !rule.method) { - throw new Error("Invalid params"); - } - - const policy = await prisma.policy.findUnique({ where: { id: policyId } }); - if (!policy) throw new Error("Policy not found"); - - await prisma.policyRule.create({ - data: { - policyId, - method: rule.method, - kind: rule.kind !== undefined && rule.kind !== null ? rule.kind.toString() : null, - maxUsageCount: rule.maxUsageCount, - currentUsageCount: 0, - } - }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/commands/add_signing_condition.ts b/src/daemon/admin/commands/add_signing_condition.ts deleted file mode 100644 index 6cfad88..0000000 --- a/src/daemon/admin/commands/add_signing_condition.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Add a SigningCondition override row for a specific KeyUser. Used to - * grant a user a (method, kind) combination beyond the policy, or to - * deny one explicitly even when the policy allows. - * - * Param shape (JSON-stringified): - * { keyUserId: number, - * condition: { method: string, - * kind?: number | "all", - * allowed: boolean } } - * - * The override layer is consulted before the live policy join in - * checkIfPubkeyAllowed (step 3 vs step 4), so `allowed: false` here - * denies regardless of the policy. - */ -export default async function addSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { keyUserId, condition } = payload; - - if (typeof keyUserId !== "number" || !condition || !condition.method || typeof condition.allowed !== "boolean") { - throw new Error("Invalid params"); - } - - const keyUser = await prisma.keyUser.findUnique({ where: { id: keyUserId } }); - if (!keyUser) throw new Error("KeyUser not found"); - - await prisma.signingCondition.create({ - data: { - keyUserId, - method: condition.method, - kind: condition.kind !== undefined && condition.kind !== null ? condition.kind.toString() : null, - allowed: condition.allowed, - } - }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/commands/remove_policy_rule.ts b/src/daemon/admin/commands/remove_policy_rule.ts deleted file mode 100644 index b983acd..0000000 --- a/src/daemon/admin/commands/remove_policy_rule.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Delete a single PolicyRule by id. Under the live-policy auth model - * (#11), removal takes effect at the next sign-time check for every - * KeyUser bound to that policy. - * - * Param shape (JSON-stringified): - * { ruleId: number } - * - * Multi-instance bunker-sharing caveat (per comment 1473 §2): - * downstream reconcilers (e.g. lnbits' lnbits-default policy - * convergence) should treat this as an admin-only op — concurrent - * removes across instance versions can race. Adds are safe, removes - * are not. - */ -export default async function removePolicyRule(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { ruleId } = payload; - - if (typeof ruleId !== "number") throw new Error("Invalid params"); - - await prisma.policyRule.delete({ where: { id: ruleId } }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/commands/remove_signing_condition.ts b/src/daemon/admin/commands/remove_signing_condition.ts deleted file mode 100644 index 65eff96..0000000 --- a/src/daemon/admin/commands/remove_signing_condition.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Delete a SigningCondition override row by id. Used to walk back a - * per-user grant or deny without affecting the policy. - * - * Param shape (JSON-stringified): - * { conditionId: number } - */ -export default async function removeSigningCondition(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { conditionId } = payload; - - if (typeof conditionId !== "number") throw new Error("Invalid params"); - - await prisma.signingCondition.delete({ where: { id: conditionId } }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/commands/revoke_token.ts b/src/daemon/admin/commands/revoke_token.ts deleted file mode 100644 index 1224d0a..0000000 --- a/src/daemon/admin/commands/revoke_token.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Revoke a single Token without affecting other tokens for the same - * KeyUser. Sets Token.revokedAt to now; the live-policy auth check - * (#11) filters tokens with `revokedAt IS NULL` so a revoked token's - * policy no longer contributes to any sign-time grant for the - * associated KeyUser. - * - * Param shape (JSON-stringified): - * { tokenId: number } - * - * The KeyUser remains intact and any other (non-revoked) tokens - * bound to it continue to grant via their own policies. Use - * revoke_user for the binary "this user is gone" case. - */ -export default async function revokeToken(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { tokenId } = payload; - - if (typeof tokenId !== "number") throw new Error("Invalid params"); - - await prisma.token.update({ - where: { id: tokenId }, - data: { revokedAt: new Date() }, - }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/commands/update_policy.ts b/src/daemon/admin/commands/update_policy.ts deleted file mode 100644 index 43028e5..0000000 --- a/src/daemon/admin/commands/update_policy.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NDKRpcRequest } from "@nostr-dev-kit/ndk"; -import AdminInterface from "../index.js"; -import prisma from "../../../db.js"; - -/** - * Update mutable fields on a Policy. Currently `name` and `expiresAt`. - * Other Policy columns (`description`, `deletedAt`, etc.) are not - * exposed by this RPC — they aren't in the ratified shape, and we want - * a single point of intent. - * - * Param shape (JSON-stringified): - * { policyId: number, - * patch: { name?: string, - * expiresAt?: string | null } } - * - * `expiresAt: null` explicitly clears the field; `expiresAt` absent - * from the patch leaves it alone. - */ -export default async function updatePolicy(admin: AdminInterface, req: NDKRpcRequest) { - const [ _payload ] = req.params as [ string ]; - - if (!_payload) throw new Error("Invalid params"); - - const payload = JSON.parse(_payload); - const { policyId, patch } = payload; - - if (typeof policyId !== "number" || !patch || typeof patch !== "object") { - throw new Error("Invalid params"); - } - - const data: { name?: string; expiresAt?: Date | null } = {}; - if (patch.name !== undefined) data.name = patch.name; - if (patch.expiresAt !== undefined) { - data.expiresAt = patch.expiresAt === null ? null : new Date(patch.expiresAt); - } - - if (Object.keys(data).length === 0) throw new Error("Empty patch"); - - await prisma.policy.update({ where: { id: policyId }, data }); - - const result = JSON.stringify(["ok"]); - return admin.rpc.sendResponse(req.id, req.pubkey, result, 24134); -} diff --git a/src/daemon/admin/index.ts b/src/daemon/admin/index.ts index 21e5f23..db8733b 100644 --- a/src/daemon/admin/index.ts +++ b/src/daemon/admin/index.ts @@ -13,12 +13,6 @@ import createNewToken from './commands/create_new_token'; import unlockKey from './commands/unlock_key'; import renameKeyUser from './commands/rename_key_user.js'; 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 addSigningCondition from './commands/add_signing_condition'; -import removeSigningCondition from './commands/remove_signing_condition'; -import revokeToken from './commands/revoke_token'; import fs from 'fs'; import { validateRequestFromAdmin } from './validations/request-from-admin'; import { dmUser } from '../../utils/dm-user'; @@ -208,12 +202,6 @@ class AdminInterface { case 'create_new_policy': await createNewPolicy(this, req); break; case 'get_policies': await this.reqListPolicies(req); break; case 'create_new_token': await createNewToken(this, req); break; - 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 '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; default: const originalKind = req.event.kind!; console.log(`Unknown method ${req.method}`); diff --git a/src/daemon/lib/acl/index.ts b/src/daemon/lib/acl/index.ts index b32f954..42af57d 100644 --- a/src/daemon/lib/acl/index.ts +++ b/src/daemon/lib/acl/index.ts @@ -1,32 +1,13 @@ import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk'; import prisma from '../../../db.js'; -/** - * Layered authorization check. Order matters: - * - * 1. fetch KeyUser; if missing → undefined (no binding exists) - * 2. if KeyUser.revokedAt set → false (binary user revoke beats everything) - * 3. SigningCondition override layer (per-user grants/denies): - * - explicit reject (method='*', allowed=false) → false - * - matching per-(method,kind) row → return row.allowed - * 4. Live policy join over KeyUser → Token → Policy → PolicyRule - * with Token.revokedAt IS NULL and a matching rule → true - * 5. else → undefined (denied) - * - * Step 3 must precede step 4: per-user denies override the policy, and - * per-user grants extend beyond the policy. Step 2 must precede step 3: - * a revoked KeyUser stays revoked regardless of conditions or policy. - * - * See aiolabs/nsecbunkerd#11 and the issue comment that ratified the - * algorithm (https://git.atitlan.io/aiolabs/nsecbunkerd/issues/11#issuecomment-1473). - */ export async function checkIfPubkeyAllowed( keyName: string, remotePubkey: string, method: IMethod, payload?: string | NostrEvent ): Promise { - // Step 1: find KeyUser. + // find KeyUser const keyUser = await prisma.keyUser.findUnique({ where: { unique_key_user: { keyName, userPubkey: remotePubkey } }, }); @@ -35,12 +16,9 @@ export async function checkIfPubkeyAllowed( return undefined; } - // Step 2: binary user revoke. - if (keyUser.revokedAt) { - return false; - } + // find SigningCondition + const signingConditionQuery = requestToSigningConditionQuery(method, payload); - // Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this). const explicitReject = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -54,9 +32,6 @@ export async function checkIfPubkeyAllowed( return false; } - // Step 3b: matching per-(method, kind) override. - const signingConditionQuery = requestToSigningConditionQuery(method, payload); - const signingCondition = await prisma.signingCondition.findFirst({ where: { keyUserId: keyUser.id, @@ -64,53 +39,32 @@ export async function checkIfPubkeyAllowed( } }); - if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) { + // if no SigningCondition found, return undefined + if (!signingCondition) { + return undefined; + } + + const allowed = signingCondition.allowed; + + // Check if the key user has been revoked + if (allowed) { + const revoked = await prisma.keyUser.findFirst({ + where: { + id: keyUser.id, + revokedAt: { not: null }, + } + }); + + if (revoked) { + return false; + } + } + + if (allowed === true || allowed === false) { console.log(`found signing condition`, signingCondition); - return signingCondition.allowed; + return allowed; } - // Step 4: live policy join. Walk every non-revoked Token bound to this - // KeyUser; if any of their policies has a matching PolicyRule, allow. - // - // PolicyRule.kind matching: - // - exact match against payload kind (stringified — matches the - // create_new_policy.ts:23 storage format `rule.kind.toString()`) - // - 'all' literal matches any kind (parity with the override-layer - // allowScopeToSigningConditionQuery convention) - // - NULL kind is a defensive branch — no current code path inserts - // PolicyRules with null kind, but if one ever appears (raw SQL, - // future code, schema migration) we treat it as a wildcard rather - // than failing closed silently. - const payloadKindString = (method === 'sign_event' && typeof payload === 'object' && payload?.kind !== undefined) - ? payload.kind.toString() - : undefined; - - const kindMatchers: Array<{ kind: string | null }> = [{ kind: null }, { kind: 'all' }]; - if (payloadKindString !== undefined) { - kindMatchers.push({ kind: payloadKindString }); - } - - const policyAllowance = await prisma.token.findFirst({ - where: { - keyUserId: keyUser.id, - revokedAt: null, - policy: { - rules: { - some: { - method, - OR: kindMatchers, - }, - }, - }, - }, - }); - - if (policyAllowance) { - return true; - } - - // Step 5: no override granted, no policy rule matched. Caller's - // requestPermission flow may still prompt the admin out-of-band. return undefined; }