Agent/server/src/audit/audit.routes.js

166 lines
5.2 KiB
JavaScript

'use strict';
const express = require('express');
const { ACTIONS } = require('@profile/shared');
const audit = require('./audit');
const db = require('../infra/db');
const rbac = require('../rbac/rbac');
const { requireAuth } = require('../auth/auth');
const { ok, asyncHandler } = require('../common/http');
const router = express.Router();
function sanitizeMcpArgs(value) {
if (Array.isArray(value)) return value.map(sanitizeMcpArgs);
if (!value || typeof value !== 'object') return value;
const out = {};
for (const [key, item] of Object.entries(value)) {
// 只过滤凭证类字段,保留业务参数(employeeId/name/year/team 等)供审计追踪
if (/(password|secret|token|_enc|_hmac)/i.test(key)) continue;
out[key] = sanitizeMcpArgs(item);
}
return out;
}
/**
* 生成人类可读的 MCP 调用摘要(供审计日志"结果摘要"列显示)
*/
function formatMcpSummary(tool, args) {
if (!args || typeof args !== 'object') return '';
const parts = [];
if (args.keyword) parts.push(`关键词:${args.keyword}`);
if (args.employeeId) parts.push(`工号:${args.employeeId}`);
if (args.employeeName) parts.push(`姓名:${args.employeeName}`);
if (args.year) parts.push(`年份:${args.year}`);
if (args.month) parts.push(`月份:${args.month}`);
if (args.team) parts.push(`班组:${args.team}`);
if (args.modules && Array.isArray(args.modules)) parts.push(`模块:${args.modules.join(',')}`);
if (args.openUi === false) parts.push('不开窗');
return parts.join('、') || JSON.stringify(args).slice(0, 200);
}
router.post(
'/view',
requireAuth,
asyncHandler(async (req, res) => {
const { page, table, filters, rowCount } = req.body || {};
const safePage = typeof page === 'string' && /^[a-z0-9_-]{1,64}$/.test(page) ? page : null;
const safeTable = typeof table === 'string' && /^[a-z0-9_]{1,64}$/.test(table) ? table : null;
if (!safePage && !safeTable) {
const err = new Error('页面或数据表标识无效');
err.status = 400;
throw err;
}
await audit.log({
userId: req.user.id,
username: req.user.username,
roleCode: req.user.roles?.[0],
clientType: 'ui',
action: safeTable ? 'view_data' : 'view_page',
module: safePage || safeTable,
targetType: safeTable ? 'table' : 'page',
targetId: safeTable || safePage,
payload: safeTable
? { filters: sanitizeMcpArgs(filters || {}), rowCount: Math.max(0, Number(rowCount) || 0) }
: null,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
ok(res, { recorded: true });
}),
);
router.post(
'/mcp',
requireAuth,
asyncHandler(async (req, res) => {
const { tool, args, success, durationMs, error } = req.body || {};
if (typeof tool !== 'string' || !/^profile_(query|get|list|health)_[a-z0-9_]+$/.test(tool)) {
const err = new Error('非法 MCP 工具名');
err.status = 400;
throw err;
}
await audit.log({
userId: req.user.id,
username: req.user.username,
roleCode: req.user.roles?.[0],
clientType: 'agent',
action: 'mcp_call',
module: 'mcp',
targetType: 'tool',
targetId: tool,
payload: {
summary: formatMcpSummary(tool, args),
args: sanitizeMcpArgs(args || {}),
success: Boolean(success),
durationMs: Math.max(0, Number(durationMs) || 0),
error: error ? String(error).slice(0, 500) : null,
},
ip: req.ip,
userAgent: req.headers['user-agent'],
});
ok(res, { recorded: true });
}),
);
router.get(
'/',
requireAuth,
rbac.requirePermission('audit', ACTIONS.READ),
asyncHandler(async (req, res) => {
const take = Math.min(Number(req.query.size) || 50, 200);
const page = Math.max(Number(req.query.page) || 1, 1);
const offset = (page - 1) * take;
const conds = [];
const params = [];
if (req.query.client_type) {
conds.push('a.client_type = ?');
params.push(req.query.client_type);
}
if (req.query.action) {
conds.push('a.action = ?');
params.push(req.query.action);
}
if (req.query.username) {
const keyword = `%${String(req.query.username).slice(0, 64)}%`;
conds.push('(a.username LIKE ? OR u.real_name LIKE ? OR u.display_name LIKE ?)');
params.push(keyword, keyword, keyword);
}
if (req.query.module) {
conds.push('a.module = ?');
params.push(String(req.query.module).slice(0, 64));
}
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
const rows = await db.query(
`SELECT a.*, u.display_name, u.real_name
FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
${where}
ORDER BY a.id DESC
LIMIT ${take} OFFSET ${offset}`,
params,
);
const countRows = await db.query(
`SELECT COUNT(*) AS total
FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id AND u.deleted_at IS NULL
${where}`,
params,
);
ok(res, { rows, total: Number(countRows[0].total), page, size: take });
}),
);
router.get(
'/verify',
requireAuth,
rbac.requirePermission('audit', ACTIONS.READ),
asyncHandler(async (req, res) => {
ok(res, await audit.verifyChain());
}),
);
module.exports = router;