Compare commits
No commits in common. "49091f722ff4d2406d150b6235099b043ec6a331" and "3ec413b70d96049b65a312497d64b163606101ae" have entirely different histories.
49091f722f
...
3ec413b70d
10 changed files with 23 additions and 315 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Token" ADD COLUMN "revokedAt" DATETIME;
|
|
||||||
|
|
@ -110,7 +110,6 @@ model Token {
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
expiresAt DateTime?
|
expiresAt DateTime?
|
||||||
redeemedAt DateTime?
|
redeemedAt DateTime?
|
||||||
revokedAt DateTime?
|
|
||||||
keyUserId Int?
|
keyUserId Int?
|
||||||
policyId Int?
|
policyId Int?
|
||||||
policy Policy? @relation(fields: [policyId], references: [id])
|
policy Policy? @relation(fields: [policyId], references: [id])
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -13,12 +13,6 @@ import createNewToken from './commands/create_new_token';
|
||||||
import unlockKey from './commands/unlock_key';
|
import unlockKey from './commands/unlock_key';
|
||||||
import renameKeyUser from './commands/rename_key_user.js';
|
import renameKeyUser from './commands/rename_key_user.js';
|
||||||
import revokeUser from './commands/revoke_user';
|
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 fs from 'fs';
|
||||||
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
||||||
import { dmUser } from '../../utils/dm-user';
|
import { dmUser } from '../../utils/dm-user';
|
||||||
|
|
@ -208,12 +202,6 @@ class AdminInterface {
|
||||||
case 'create_new_policy': await createNewPolicy(this, req); break;
|
case 'create_new_policy': await createNewPolicy(this, req); break;
|
||||||
case 'get_policies': await this.reqListPolicies(req); break;
|
case 'get_policies': await this.reqListPolicies(req); break;
|
||||||
case 'create_new_token': await createNewToken(this, 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:
|
default:
|
||||||
const originalKind = req.event.kind!;
|
const originalKind = req.event.kind!;
|
||||||
console.log(`Unknown method ${req.method}`);
|
console.log(`Unknown method ${req.method}`);
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,13 @@
|
||||||
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
|
import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||||
import prisma from '../../../db.js';
|
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(
|
export async function checkIfPubkeyAllowed(
|
||||||
keyName: string,
|
keyName: string,
|
||||||
remotePubkey: string,
|
remotePubkey: string,
|
||||||
method: IMethod,
|
method: IMethod,
|
||||||
payload?: string | NostrEvent
|
payload?: string | NostrEvent
|
||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
// Step 1: find KeyUser.
|
// find KeyUser
|
||||||
const keyUser = await prisma.keyUser.findUnique({
|
const keyUser = await prisma.keyUser.findUnique({
|
||||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||||
});
|
});
|
||||||
|
|
@ -35,12 +16,9 @@ export async function checkIfPubkeyAllowed(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: binary user revoke.
|
// find SigningCondition
|
||||||
if (keyUser.revokedAt) {
|
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3a: explicit-reject override (rejectAllRequestsFromKey writes this).
|
|
||||||
const explicitReject = await prisma.signingCondition.findFirst({
|
const explicitReject = await prisma.signingCondition.findFirst({
|
||||||
where: {
|
where: {
|
||||||
keyUserId: keyUser.id,
|
keyUserId: keyUser.id,
|
||||||
|
|
@ -54,9 +32,6 @@ export async function checkIfPubkeyAllowed(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3b: matching per-(method, kind) override.
|
|
||||||
const signingConditionQuery = requestToSigningConditionQuery(method, payload);
|
|
||||||
|
|
||||||
const signingCondition = await prisma.signingCondition.findFirst({
|
const signingCondition = await prisma.signingCondition.findFirst({
|
||||||
where: {
|
where: {
|
||||||
keyUserId: keyUser.id,
|
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);
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue