Fix MCP permissions and repayments; restrict deletion to default admin

This commit is contained in:
陈煜 committed 2026-10-05 20:26:17 +08:00
1 parent 35bd2e1828
commit 794274d31b
21 files changed
+702 -52

No files matched your search

+2 -1
View File
@@ -135,7 +135,8 @@ export class AdminService {
throw new ForbiddenException('管理员权限已变更');
const target = await tx.user.findUnique({ where: { id } });
if (!target) throw new NotFoundException('账号不存在');
if (target.role !== 'admin') throw new BadRequestException('仅支持删除管理员账号');
if (target.username !== 'admin' || target.role !== 'admin')
throw new ForbiddenException('仅允许删除默认 admin 账号,其他账号不可删除');
if (v.confirmationUsername !== target.username)
throw new BadRequestException('确认账号名称不一致,请重新核对');
if (
+1 -1
View File
@@ -193,7 +193,7 @@ export class AgentCatalogue {
),
write(
'movement_create',
'执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。',
'执行 transfer 转账、borrow 借入、lend 借出、collect 收款、repay 还款。amount 为本金,received 为到账/债务本金,fee 可负表示优惠;原币十进制字符串,双边事务和余额检查。repay 的 sourceId 必须是资产账户,targetId 可以是借入债务或信用卡等负债账户;负债账户超额还款保留为存款。',
transferInput.safeExtend({ requestId: z.never().optional() }),
(r, p) => transfers.create(r, p),
),
+51
View File
@@ -2,6 +2,8 @@ import {
Controller,
Get,
Post,
Patch,
NotFoundException,
Delete,
Req,
Param,
@@ -11,9 +13,11 @@ import {
ForbiddenException,
HttpException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { compare } from 'bcryptjs';
import { Response } from 'express';
import { z } from 'zod';
import { loginInput } from '../user-access';
import { Database } from '../database';
import { AuthService, UserRequest } from '../auth';
import { AgentOAuth, urls, scopeInput, oauthDays } from './oauth';
@@ -96,6 +100,53 @@ export class AgentManagementController {
return { id: v.grant.id, token: v.tokens.access_token, expiresAt: v.grant.expiresAt };
});
}
@Patch('connections/:id') async permissions(
@Req() r: UserRequest,
@Param('id') id: string,
@Body() raw: unknown,
) {
z.string().uuid().parse(id);
const p = z
.object({ scopes: scopeInput, password: loginInput.shape.password })
.strict()
.parse(raw);
this.auth.limit(r);
const verified = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (!(await compare(p.password, verified.passwordHash)))
throw new ForbiddenException('密码错误');
return this.db.atomic(async () => {
await this.db.$queryRaw(Prisma.sql`SELECT id FROM User WHERE id=${r.userId} FOR UPDATE`);
const user = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
if (user.banned || user.mustChangePassword || user.passwordHash !== verified.passwordHash)
throw new ForbiddenException('账号状态已变化,请重新登录');
if (
user.role === 'readonly' &&
p.scopes.some((s) => ['draft', 'write', 'hidden_write'].includes(s))
)
throw new ForbiddenException('只读账号只能授予查询权限');
// Refresh and edits lock the same grant before reading its permissions.
await this.db.$queryRaw(
Prisma.sql`SELECT id FROM AgentGrant WHERE id=${id} AND userId=${r.userId} FOR UPDATE`,
);
const grant = await this.db.agentGrant.findFirst({ where: { id, userId: r.userId } });
const expiry = grant?.clientId ? grant.refreshExpiresAt : grant?.expiresAt;
if (!grant || grant.revokedAt || (expiry && expiry <= new Date()))
throw new NotFoundException('有效连接不存在');
const previous = grant.scopes as string[];
if (
previous.length === p.scopes.length &&
previous.every((s) => (p.scopes as string[]).includes(s))
)
return { ok: true };
await this.db.agentGrant.update({ where: { id }, data: { scopes: p.scopes } });
// Permission edits never execute old drafts under a newly granted privilege.
await this.db.agentOperation.updateMany({
where: { grantId: id, userId: r.userId, status: 'pending' },
data: { status: 'cancelled', completedAt: new Date() },
});
return { ok: true };
});
}
@Delete('connections/:id') async revoke(@Req() r: UserRequest, @Param('id') id: string) {
z.string().uuid().parse(id);
await this.db.agentGrant.updateMany({
+4
View File
@@ -1,6 +1,7 @@
import { networkConfig } from '../network';
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { randomBytes, randomUUID, createHash } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { Response } from 'express';
import { z } from 'zod';
import { Database } from '../database';
@@ -324,6 +325,9 @@ export class AgentOAuth implements OAuthServerProvider {
) {
this.resource(resource);
return this.db.atomic(async () => {
await this.db.$queryRaw(
Prisma.sql`SELECT id FROM AgentGrant WHERE refreshDigest=${digest(token)} FOR UPDATE`,
);
const row = await this.db.agentGrant.findUnique({ where: { refreshDigest: digest(token) } });
if (
!row ||
+10
View File
@@ -1,3 +1,4 @@
import { scopeInput } from './mcp/oauth';
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { metalConfig, metalHoldingInput } from './metals';
@@ -38,6 +39,9 @@ export function setupOpenApi(app: INestApplication) {
'PATCH /api/admin/users/{id}': adminUpdateInput,
'DELETE /api/admin/users/{id}': adminDeleteInput,
'PATCH /api/auth/credentials': credentialChange,
'PATCH /api/agent/connections/{id}': z
.object({ scopes: scopeInput, password: loginInput.shape.password })
.strict(),
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
'POST /api/positions': positionInput,
'PATCH /api/positions/{id}': positionMeta,
@@ -101,6 +105,12 @@ export function setupOpenApi(app: INestApplication) {
description: '{ items, nextCursor, revealed };金额为绝对余额,before 为真实前序余额',
};
}
if (method === 'patch' && path === '/api/agent/connections/{id}')
operation.description =
'验证当前密码后修改自己的有效 OAuth/PAT 连接权限;实际权限变更取消未确认草稿,不延长授权期限。只读用户不能授予写入。';
if (['post', 'put'].includes(method) && path.startsWith('/api/transfers'))
operation.description =
'repay 从资产账户向借入债务或负债账户还款;负债账户超额还款保存为溢缴存款。amount/received 为原币本金,fee 负数表示优惠。同币种本金一致,双边金额与历史原子更新。';
if (method === 'get' && path === '/api/trend') {
operation.parameters = [
...['from', 'to'].map((name) => ({
+6 -4
View File
@@ -183,10 +183,12 @@ export async function executeMovement(
(v.operation !== 'transfer' && source.side !== 'asset') ||
(v.operation === 'transfer'
? target.kind !== 'account'
: target.kind !== 'debt' ||
target.side !== (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset'))
: !(
(target.kind === 'debt' || (v.operation === 'repay' && target.kind === 'account')) &&
target.side === (['borrow', 'repay'].includes(v.operation) ? 'liability' : 'asset')
))
)
throw new BadRequestException('请选择有效的资产账户和对应借入或借出债务');
throw new BadRequestException('请选择资产账户及对应债务;还款也可选择负债账户');
if (accounts.some((p) => p.revisions[0] && +p.revisions[0].effectiveDate > +when))
throw new ConflictException('转账时间不能早于任一账户的最新余额记录,请以当前余额转账');
if (source.currency === target.currency && !new Decimal(v.amount).eq(v.received))
@@ -235,7 +237,7 @@ export async function executeMovement(
notes: v.notes,
},
});
if (v.operation !== 'transfer')
if (v.operation !== 'transfer' && target.kind === 'debt')
await tx.positionLink.upsert({
where: { sourceId_targetId: { sourceId: target.id, targetId: source.id } },
create: { sourceId: target.id, targetId: source.id },