feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt #13
10 changed files with 315 additions and 23 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME;
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
47
src/daemon/admin/commands/add_policy_rule.ts
Normal file
47
src/daemon/admin/commands/add_policy_rule.ts
Normal 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);
|
||||
}
|
||||
46
src/daemon/admin/commands/add_signing_condition.ts
Normal file
46
src/daemon/admin/commands/add_signing_condition.ts
Normal 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);
|
||||
}
|
||||
33
src/daemon/admin/commands/remove_policy_rule.ts
Normal file
33
src/daemon/admin/commands/remove_policy_rule.ts
Normal 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);
|
||||
}
|
||||
26
src/daemon/admin/commands/remove_signing_condition.ts
Normal file
26
src/daemon/admin/commands/remove_signing_condition.ts
Normal 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);
|
||||
}
|
||||
36
src/daemon/admin/commands/revoke_token.ts
Normal file
36
src/daemon/admin/commands/revoke_token.ts
Normal 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);
|
||||
}
|
||||
43
src/daemon/admin/commands/update_policy.ts
Normal file
43
src/daemon/admin/commands/update_policy.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue