feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt #13

Merged
padreug merged 3 commits from issue-11-live-policy-auth into dev 2026-05-30 15:25:16 +00:00
10 changed files with 315 additions and 23 deletions

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME;

View file

@ -110,6 +110,7 @@ model Token {
deletedAt DateTime?
expiresAt DateTime?
redeemedAt DateTime?
revokedAt DateTime?
keyUserId Int?
policyId Int?
policy Policy? @relation(fields: [policyId], references: [id])

View file

@ -0,0 +1,47 @@
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);
}

View file

@ -0,0 +1,46 @@
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);
}

View file

@ -0,0 +1,33 @@
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);
}

View file

@ -0,0 +1,26 @@
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);
}

View file

@ -0,0 +1,36 @@
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);
}

View file

@ -0,0 +1,43 @@
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);
}

View file

@ -13,6 +13,12 @@ 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';
@ -202,6 +208,12 @@ 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}`);

View file

@ -1,13 +1,32 @@
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<boolean | undefined> {
// find KeyUser
// Step 1: find KeyUser.
const keyUser = await prisma.keyUser.findUnique({
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
});
@ -16,9 +35,12 @@ export async function checkIfPubkeyAllowed(
return undefined;
}
// find SigningCondition
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
// Step 2: binary user revoke.
if (keyUser.revokedAt) {
return false;
}
// Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this).
const explicitReject = await prisma.signingCondition.findFirst({
where: {
keyUserId: keyUser.id,
@ -32,6 +54,9 @@ 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,
@ -39,32 +64,53 @@ export async function checkIfPubkeyAllowed(
}
});
// 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) {
if (signingCondition && (signingCondition.allowed === true || signingCondition.allowed === false)) {
console.log(`found signing condition`, signingCondition);
return allowed;
return signingCondition.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;
}