Fix MCP permissions and repayments; restrict deletion to default admin
This commit is contained in:
1 parent
35bd2e1828
commit
794274d31b
21 files changed
+702
-52
No files matched your search
@@ -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 (
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in new issue
Block a user