feat(#11): live-policy auth + 6 companion admin RPCs + Token.revokedAt #13
7 changed files with 243 additions and 0 deletions
feat(admin): companion RPCs for live policy + token revocation (#11)
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Some checks failed
Docker image / build-and-push-image (push) Has been cancelled
Six new admin-RPC handlers that complement the live-policy auth rewrite. Each follows the canonical create_new_policy.ts pattern (single JSON-stringified param, kind-24134 response, one prisma mutation, no per-call ACL — validateRequestFromAdmin gates them via the admin-npub allowlist). Policy-level mutations propagate to every KeyUser bound to the policy at the next sign-time check (the point of #11): add_policy_rule { policyId, rule: {method, kind?, maxUsageCount?} } remove_policy_rule { ruleId } update_policy { policyId, patch: {name?, expiresAt?} } Per-KeyUser override mutations (override layer): add_signing_condition { keyUserId, condition: {method, kind?, allowed} } remove_signing_condition { conditionId } Surgical token revocation without nuking the KeyUser: revoke_token { tokenId } The 'all' kind literal is honored as a wildcard in add_policy_rule for parity with the SigningCondition override-layer convention. Param shapes ratified by webapp in #11 (comment). refs: #11
commit
49091f722f
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}`);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue