forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
16
.env.example
16
.env.example
@@ -13,3 +13,19 @@ DB_SYNCHRONIZE=false
|
||||
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||
JWT_EXPIRES_IN=24h
|
||||
PORT=3000
|
||||
|
||||
# ---- AI 模型配置 ----
|
||||
# AES-256-GCM 加密主密钥,用于加密存储 API Key
|
||||
# 生产环境必须设置,生成方式:openssl rand -hex 32
|
||||
# 格式:64 位 hex(推荐)或 base64 编码后恰好 32 字节
|
||||
# 示例 hex:openssl rand -hex 32
|
||||
# 示例 base64:openssl rand -base64 32
|
||||
AI_CONFIG_ENCRYPTION_KEY=
|
||||
|
||||
# AI API Key 环境变量回退(可选)
|
||||
# 若数据库未保存 Key,将从该环境变量读取
|
||||
# 环境变量 Key 不可从页面覆盖或清除
|
||||
# AI_API_KEY=
|
||||
|
||||
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
||||
# AI_ALLOW_PRIVATE_BASE_URL=true
|
||||
|
||||
@@ -32,6 +32,7 @@ const AttendancePage = lazy(() => import('./pages/Attendance'));
|
||||
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'));
|
||||
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
const AiConfigPage = lazy(() => import('./pages/AiConfig'));
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -274,6 +275,15 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="ai-config"
|
||||
element={
|
||||
<PermissionRoute permission="ai:config:read">
|
||||
<AiConfigPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
LaptopOutlined,
|
||||
BellOutlined,
|
||||
ApiOutlined,
|
||||
RobotOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
@@ -115,6 +116,7 @@ const allMenuItems: MenuItemType[] = [
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
||||
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
|
||||
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
|
||||
{ key: '/ai-config', icon: <RobotOutlined />, label: 'AI 模型配置', permission: 'ai:config:read' },
|
||||
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
|
||||
],
|
||||
},
|
||||
|
||||
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal file
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
shouldAutoSwapBaseUrl,
|
||||
formatDateTime,
|
||||
sourceLabel,
|
||||
sourceColor,
|
||||
PROVIDER_DEFAULTS,
|
||||
extractErrorMessage,
|
||||
} from './helpers';
|
||||
|
||||
describe('AiConfig helpers', () => {
|
||||
describe('shouldAutoSwapBaseUrl', () => {
|
||||
it('swaps to default on first provider selection', () => {
|
||||
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', null);
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
|
||||
});
|
||||
|
||||
it('swaps when current baseUrl matches previous provider default', () => {
|
||||
const result = shouldAutoSwapBaseUrl(
|
||||
'DEEPSEEK',
|
||||
'https://api.openai.com/v1',
|
||||
'OPENAI',
|
||||
);
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
|
||||
});
|
||||
|
||||
it('swaps when current baseUrl is empty', () => {
|
||||
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', 'OPENAI');
|
||||
expect(result.shouldSwap).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps custom baseUrl unchanged', () => {
|
||||
const result = shouldAutoSwapBaseUrl(
|
||||
'OPENAI',
|
||||
'https://custom.api.com/v1',
|
||||
'DEEPSEEK',
|
||||
);
|
||||
expect(result.shouldSwap).toBe(false);
|
||||
expect(result.baseUrl).toBe('https://custom.api.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDateTime', () => {
|
||||
it('returns hyphen for null', () => {
|
||||
expect(formatDateTime(null)).toBe('-');
|
||||
});
|
||||
|
||||
it('returns Chinese locale string for ISO date', () => {
|
||||
expect(formatDateTime('2024-01-15T10:30:00Z')).toContain('2024');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceLabel', () => {
|
||||
it('returns Chinese labels', () => {
|
||||
expect(sourceLabel('database')).toBe('数据库');
|
||||
expect(sourceLabel('environment')).toBe('环境变量');
|
||||
expect(sourceLabel('none')).toBe('未配置');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sourceColor', () => {
|
||||
it('returns correct colors', () => {
|
||||
expect(sourceColor('database')).toBe('green');
|
||||
expect(sourceColor('environment')).toBe('blue');
|
||||
expect(sourceColor('none')).toBe('default');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
it('extracts axios-style response error message', () => {
|
||||
const err = {
|
||||
response: { data: { message: 'API出错' } },
|
||||
};
|
||||
expect(extractErrorMessage(err)).toBe('API出错');
|
||||
});
|
||||
|
||||
it('falls back to message property', () => {
|
||||
const err = { message: 'Network error' };
|
||||
expect(extractErrorMessage(err)).toBe('Network error');
|
||||
});
|
||||
|
||||
it('falls back to default on unknown type', () => {
|
||||
expect(extractErrorMessage('unknown string')).toBe('操作失败');
|
||||
expect(extractErrorMessage(null)).toBe('操作失败');
|
||||
expect(extractErrorMessage(undefined)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: newlines replaced with spaces', () => {
|
||||
const err = { message: 'line1\nline2\r\nline3' };
|
||||
expect(extractErrorMessage(err)).toBe('line1 line2 line3');
|
||||
});
|
||||
|
||||
it('sanitizes: message > 120 chars truncated with ellipsis', () => {
|
||||
const long = 'x'.repeat(200);
|
||||
const err = { message: long };
|
||||
const result = extractErrorMessage(err);
|
||||
expect(result).toHaveLength(121); // 120 + '…' (1 char)
|
||||
expect(result.endsWith('\u2026')).toBe(true);
|
||||
});
|
||||
|
||||
it('sanitizes: empty trimmed message falls back', () => {
|
||||
const err = { message: ' ' };
|
||||
expect(extractErrorMessage(err)).toBe('操作失败');
|
||||
});
|
||||
|
||||
it('sanitizes: plain object message property sanitized', () => {
|
||||
const err = {
|
||||
message: ' some \n\nerror \r\nmessage ',
|
||||
};
|
||||
expect(extractErrorMessage(err)).toBe('some error message');
|
||||
});
|
||||
});
|
||||
});
|
||||
89
apps/admin/src/pages/AiConfig/helpers.ts
Normal file
89
apps/admin/src/pages/AiConfig/helpers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// AiConfig helpers — pure functions, no React / DOM dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
|
||||
|
||||
export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
|
||||
{ value: 'OPENAI', label: 'OpenAI' },
|
||||
{ value: 'DEEPSEEK', label: 'DeepSeek' },
|
||||
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
|
||||
];
|
||||
|
||||
export const PROVIDER_DEFAULTS: Record<AiProvider, string> = {
|
||||
OPENAI: 'https://api.openai.com/v1',
|
||||
DEEPSEEK: 'https://api.deepseek.com',
|
||||
OPENAI_COMPATIBLE: '',
|
||||
} as const;
|
||||
|
||||
export const FIXED_PROVIDERS: AiProvider[] = ['OPENAI', 'DEEPSEEK'];
|
||||
|
||||
export function formatDateTime(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'database':
|
||||
return '数据库';
|
||||
case 'environment':
|
||||
return '环境变量';
|
||||
default:
|
||||
return '未配置';
|
||||
}
|
||||
}
|
||||
|
||||
export function sourceColor(source: string): 'green' | 'blue' | 'default' {
|
||||
switch (source) {
|
||||
case 'database':
|
||||
return 'green';
|
||||
case 'environment':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldAutoSwapBaseUrl(
|
||||
provider: AiProvider,
|
||||
currentBaseUrl: string,
|
||||
lastProvider: AiProvider | null,
|
||||
): { baseUrl: string; shouldSwap: boolean } {
|
||||
if (!lastProvider) {
|
||||
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
|
||||
}
|
||||
const prevDefault = PROVIDER_DEFAULTS[lastProvider];
|
||||
if (!currentBaseUrl || currentBaseUrl === prevDefault) {
|
||||
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
|
||||
}
|
||||
return { baseUrl: currentBaseUrl, shouldSwap: false };
|
||||
}
|
||||
|
||||
/** Extract a safe user-facing error message from any caught value */
|
||||
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
|
||||
let msg = '';
|
||||
|
||||
// axios-style error: { response: { data: { message: string } } }
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp: unknown = err.response;
|
||||
if (resp && typeof resp === 'object' && 'data' in resp) {
|
||||
const data: unknown = resp.data;
|
||||
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
|
||||
msg = data.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// standard Error or any object with a string message property
|
||||
if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
// sanitize: trim, collapse whitespace, strip newlines, truncate
|
||||
const trimmed = msg.trim();
|
||||
if (!trimmed) return fallback;
|
||||
|
||||
const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');
|
||||
return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine;
|
||||
}
|
||||
73
apps/admin/src/pages/AiConfig/index.module.css
Normal file
73
apps/admin/src/pages/AiConfig/index.module.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.headerDesc {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.testResult {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.safetyNote {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.safetyNoteKey {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: #d46b08;
|
||||
}
|
||||
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
506
apps/admin/src/pages/AiConfig/index.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
App,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Button,
|
||||
Select,
|
||||
Switch,
|
||||
InputNumber,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Spin,
|
||||
Alert,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
ApiOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
KeyOutlined,
|
||||
DeleteOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import type { AiProvider } from './helpers';
|
||||
import {
|
||||
PROVIDER_OPTIONS,
|
||||
PROVIDER_DEFAULTS,
|
||||
FIXED_PROVIDERS,
|
||||
formatDateTime,
|
||||
sourceLabel,
|
||||
sourceColor,
|
||||
shouldAutoSwapBaseUrl,
|
||||
extractErrorMessage,
|
||||
} from './helpers';
|
||||
import styles from './index.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AiConfigData {
|
||||
id: number;
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
hasDatabaseKey: boolean;
|
||||
maskedApiKey: string | null;
|
||||
keySource: 'database' | 'environment' | 'none';
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
lastTestLatencyMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
modelAvailable: boolean;
|
||||
testedAt: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AiConfigPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const { modal } = App.useApp();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<AiConfigData | null>(null);
|
||||
const [testResult, setTestResult] = useState<TestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const lastProviderRef = useRef<AiProvider | null>(null);
|
||||
|
||||
const canWrite = hasPermission('ai:config:write');
|
||||
const canTest = hasPermission('ai:config:test');
|
||||
const canRead = hasPermission('ai:config:read');
|
||||
|
||||
// ── Load config ──
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
|
||||
setConfig(res.data);
|
||||
form.setFieldsValue({
|
||||
provider: res.data.provider,
|
||||
baseUrl: res.data.baseUrl,
|
||||
defaultModel: res.data.defaultModel ?? undefined,
|
||||
enabled: res.data.enabled,
|
||||
timeoutMs: res.data.timeoutMs,
|
||||
});
|
||||
lastProviderRef.current = res.data.provider;
|
||||
} catch (err: unknown) {
|
||||
setError(extractErrorMessage(err, '加载配置失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
// ── Provider change → swap baseUrl ──
|
||||
|
||||
const handleProviderChange = useCallback(
|
||||
(provider: AiProvider) => {
|
||||
const currentBaseUrl = form.getFieldValue('baseUrl') || '';
|
||||
const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current);
|
||||
if (result.shouldSwap) {
|
||||
form.setFieldValue('baseUrl', result.baseUrl);
|
||||
}
|
||||
lastProviderRef.current = provider;
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined;
|
||||
const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false;
|
||||
|
||||
// ── Save ──
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
|
||||
// Validate baseUrl for OPENAI_COMPATIBLE
|
||||
if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) {
|
||||
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
provider: values.provider,
|
||||
baseUrl: values.baseUrl,
|
||||
defaultModel: values.defaultModel || undefined,
|
||||
enabled: values.enabled,
|
||||
timeoutMs: values.timeoutMs,
|
||||
};
|
||||
|
||||
if (values.apiKey && values.apiKey !== '••••') {
|
||||
body.apiKey = values.apiKey;
|
||||
}
|
||||
|
||||
await api.put('/ai/config', body);
|
||||
message.success('配置已保存');
|
||||
form.setFieldValue('apiKey', '');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '保存失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [form, loadConfig]);
|
||||
|
||||
// ── Test connection ──
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
try {
|
||||
// Validated fields: compatible requires baseUrl
|
||||
const fieldsToValidate = ['timeoutMs'] as string[];
|
||||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||||
fieldsToValidate.push('baseUrl');
|
||||
}
|
||||
const values = await form.validateFields(fieldsToValidate);
|
||||
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
timeoutMs: values.timeoutMs,
|
||||
};
|
||||
|
||||
// Always send provider if form has it
|
||||
if (currentProvider) body.provider = currentProvider;
|
||||
if (values.baseUrl) body.baseUrl = values.baseUrl;
|
||||
|
||||
// Include defaultModel so backend checks target model
|
||||
const defaultModel = form.getFieldValue('defaultModel');
|
||||
if (defaultModel) body.defaultModel = defaultModel;
|
||||
|
||||
const typedKey = form.getFieldValue('apiKey');
|
||||
if (typedKey && typedKey !== '••••') {
|
||||
body.apiKey = typedKey;
|
||||
}
|
||||
|
||||
const res = await api.post<TestResult>('/ai/config/test', body);
|
||||
setTestResult(res);
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: new Date().toISOString(),
|
||||
message: extractErrorMessage(err, '测试请求失败'),
|
||||
});
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}, [form, loadConfig, currentProvider]);
|
||||
|
||||
// ── Clear key ──
|
||||
|
||||
const handleClearKey = useCallback(() => {
|
||||
const isEnv = config?.keySource === 'environment';
|
||||
modal.confirm({
|
||||
title: '确认清除密钥',
|
||||
content: isEnv
|
||||
? '数据库中的密钥将被清除,但环境变量 AI_API_KEY 仍可使用。确定继续?'
|
||||
: '密钥将被永久清除,之后将无法使用 AI 功能。确定继续?',
|
||||
okText: '确认清除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post('/ai/config/clear-key');
|
||||
message.success('密钥已清除');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '清除失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [config, loadConfig, modal]);
|
||||
|
||||
// ── No read permission ──
|
||||
|
||||
if (!canRead) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Alert type="error" title="您没有查看 AI 配置的权限" showIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !config) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Alert type="error" title={error} showIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<h2>AI 模型配置</h2>
|
||||
<p className={styles.headerDesc}>密钥仅保存在服务器端,浏览器无法读取明文</p>
|
||||
<div className={styles.statusRow}>
|
||||
<Space size="small">
|
||||
<Tag color={config?.enabled ? 'green' : 'default'}>
|
||||
{config?.enabled ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
{config?.verified && <Tag color="blue">已验证</Tag>}
|
||||
{config?.hasApiKey && (
|
||||
<Tag color={sourceColor(config?.keySource || 'none')}>
|
||||
密钥: {sourceLabel(config?.keySource || 'none')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="vertical" initialValues={{ timeoutMs: 30000, enabled: false }}>
|
||||
<div className={styles.grid}>
|
||||
{/* Left: 模型路由 */}
|
||||
<Card title={<span className={styles.cardTitle}>模型路由</span>} extra={<ApiOutlined />}>
|
||||
<Form.Item
|
||||
name="provider"
|
||||
label="Provider"
|
||||
rules={[{ required: true, message: '请选择 Provider' }]}
|
||||
>
|
||||
<Select
|
||||
options={PROVIDER_OPTIONS}
|
||||
onChange={handleProviderChange}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="Base URL"
|
||||
rules={[
|
||||
{ required: true, message: '请输入 Base URL' },
|
||||
{ type: 'url', message: '请输入合法的 URL' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
config?.provider
|
||||
? PROVIDER_DEFAULTS[config.provider]
|
||||
: 'https://api.openai.com/v1'
|
||||
}
|
||||
disabled={!canWrite || (isFixedProvider && canWrite)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.enabled !== curr.enabled}>
|
||||
{({ getFieldValue }) => {
|
||||
const enabled = getFieldValue('enabled');
|
||||
return (
|
||||
<Form.Item
|
||||
name="defaultModel"
|
||||
label="默认模型"
|
||||
rules={enabled ? [{ required: true, message: '启用时默认模型为必填项' }] : []}
|
||||
>
|
||||
<Input placeholder="例如: gpt-4, deepseek-chat" disabled={!canWrite} />
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch disabled={!canWrite} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="timeoutMs"
|
||||
label="请求超时 (毫秒)"
|
||||
rules={[
|
||||
{ required: true, message: '请输入超时时间' },
|
||||
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
|
||||
]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1000}
|
||||
max={120000}
|
||||
step={1000}
|
||||
style={{ width: '100%' }}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
{/* Right: 密钥保险库 */}
|
||||
<Card
|
||||
title={<span className={styles.cardTitle}>密钥保险库</span>}
|
||||
extra={<KeyOutlined />}
|
||||
>
|
||||
<Form.Item name="apiKey" label="API Key">
|
||||
<Input.Password
|
||||
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
|
||||
disabled={!canWrite}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{config && (
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="状态">
|
||||
{config.hasApiKey ? (
|
||||
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
|
||||
) : (
|
||||
<Tag color="default">未配置</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">
|
||||
<Tag color={sourceColor(config.keySource)}>{sourceLabel(config.keySource)}</Tag>
|
||||
{config.keySource === 'environment' && (
|
||||
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
|
||||
由环境变量托管,需在服务器修改
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="最后更新">
|
||||
{formatDateTime(config.updatedAt)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{config?.hasDatabaseKey && canWrite && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} onClick={handleClearKey}>
|
||||
清除服务器保存密钥
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config?.keySource === 'environment' && !config.hasDatabaseKey && (
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: '#999' }}>
|
||||
密钥由环境变量提供,无法通过页面清除
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.safetyNote}>
|
||||
API Key 使用 AES-256-GCM 加密存储,每次保存使用随机 IV。 传输层通过 HTTPS
|
||||
保护,服务端日志不记录密钥。
|
||||
</div>
|
||||
<div className={styles.safetyNoteKey}>
|
||||
也可通过环境变量 <Typography.Text code>AI_API_KEY</Typography.Text> 注入密钥,
|
||||
环境变量优先级高于数据库存储。
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className={styles.actions}>
|
||||
<Tooltip title={!canWrite ? '当前角色无写入权限' : undefined}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSave}
|
||||
loading={saving}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={!canTest ? '当前角色无测试权限' : undefined}>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
onClick={handleTest}
|
||||
loading={testing}
|
||||
disabled={!canTest}
|
||||
>
|
||||
测试连接
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<Card size="small" className={styles.testResult}>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
|
||||
<Descriptions.Item label="结果">
|
||||
{testResult.success ? (
|
||||
testResult.modelAvailable ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
成功
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag icon={<WarningOutlined />} color="warning">
|
||||
模型未找到
|
||||
</Tag>
|
||||
)
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||
失败
|
||||
</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="延迟">
|
||||
{testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="模型数量">
|
||||
{testResult.modelCount != null ? testResult.modelCount : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="测试时间">
|
||||
{formatDateTime(testResult.testedAt)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Alert
|
||||
type={
|
||||
testResult.success ? (testResult.modelAvailable ? 'success' : 'warning') : 'error'
|
||||
}
|
||||
title={testResult.message}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiConfigPage;
|
||||
@@ -8,10 +8,9 @@
|
||||
"scripts": {
|
||||
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json",
|
||||
"build": "nest build -p tsconfig.build.json",
|
||||
"start:dev": "SEED_DEV=true nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"format": "oxfmt",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
@@ -23,6 +22,7 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@casl/ability": "^7.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
|
||||
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal file
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
|
||||
import { AuthorizationService } from '../authorization/authorization.service';
|
||||
import { AgentToolRegistry } from './agent-tool.registry';
|
||||
import { AgentToolExecutor } from './agent-tool.executor';
|
||||
import { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
|
||||
import type { ToolDef, ToolInputResult, ToolDescriptor } from './agent-tool.types';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const abilityFactory = new CaslAbilityFactory();
|
||||
|
||||
/** Create context via the factory (the ONLY valid path). */
|
||||
function makeCtx(user: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
|
||||
const u: AuthenticatedUser = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
permissions: user.permissions ?? [],
|
||||
isSuperAdmin: user.isSuperAdmin ?? false,
|
||||
roles: user.roles ?? [],
|
||||
};
|
||||
return AgentToolContextFactory.fromAuthenticatedUser(u);
|
||||
}
|
||||
|
||||
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
|
||||
const studentViewerCtx = makeCtx({
|
||||
id: 2,
|
||||
username: 'teacher_zhang',
|
||||
permissions: ['student:view'],
|
||||
});
|
||||
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
|
||||
|
||||
/** Create a simple mock tool. */
|
||||
function makeTool(overrides: Partial<ToolDef> = {}): ToolDef {
|
||||
return {
|
||||
name: 'echo',
|
||||
description: 'echoes input',
|
||||
requiredPermission: 'student:view',
|
||||
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, additionalProperties: false },
|
||||
validate(input: Record<string, unknown>): ToolInputResult<Record<string, unknown>> {
|
||||
const forbidden = new Set(['userId', 'isSuperAdmin', 'permissions', 'roles', 'ability']);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (forbidden.has(key)) return { ok: false, error: `禁止字段: ${key}` };
|
||||
}
|
||||
return { ok: true, value: input };
|
||||
},
|
||||
async execute(input: Record<string, unknown>): Promise<unknown> {
|
||||
return { echoed: input };
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(opLogMock?: { log: jest.Mock }): {
|
||||
executor: AgentToolExecutor;
|
||||
registry: AgentToolRegistry;
|
||||
opLog: { log: jest.Mock };
|
||||
} {
|
||||
const authz = new AuthorizationService(abilityFactory);
|
||||
const registry = new AgentToolRegistry();
|
||||
const opLog = opLogMock ?? { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const executor = new AgentToolExecutor(registry, abilityFactory, authz, opLog as never);
|
||||
return { executor, registry, opLog };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AgentToolContext — immutability & forgery resistance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AgentToolContext — immutability & forgery resistance', () => {
|
||||
it('context is fully frozen (cannot add/remove/modify properties)', () => {
|
||||
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
|
||||
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
|
||||
});
|
||||
|
||||
// Frozen object throws in strict mode on mutation
|
||||
expect(() => {
|
||||
(ctx as Record<string, unknown>).isSuperAdmin = true;
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
(ctx as Record<string, unknown>).userId = 999;
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
(ctx as Record<string, unknown>).newField = 'injected';
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('permissions array is frozen (cannot push/splice)', () => {
|
||||
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
|
||||
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
|
||||
});
|
||||
expect(() => {
|
||||
(ctx.permissions as string[]).push('student:delete');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('mutating original AuthenticatedUser does NOT affect context', () => {
|
||||
const user: AuthenticatedUser = {
|
||||
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
|
||||
};
|
||||
const ctx = AgentToolContextFactory.fromAuthenticatedUser(user);
|
||||
|
||||
user.permissions.push('superadmin:hack');
|
||||
user.isSuperAdmin = true;
|
||||
|
||||
expect(ctx.permissions).toEqual(['student:view']);
|
||||
expect(ctx.isSuperAdmin).toBe(false);
|
||||
});
|
||||
|
||||
it('hand-crafted plain-object context is rejected by executor.execute', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const fakeCtx = {
|
||||
userId: 1,
|
||||
username: 'hacker',
|
||||
permissions: Object.freeze(['student:view', 'student:delete']),
|
||||
isSuperAdmin: true,
|
||||
} as AgentToolContext;
|
||||
|
||||
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx);
|
||||
expect(result.status).toBe('denied');
|
||||
expect(result.error).toBe('权限不足');
|
||||
expect(result.result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hand-crafted plain-object context is rejected by executor.listAvailable', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const fakeCtx = {
|
||||
userId: 1,
|
||||
username: 'hacker',
|
||||
permissions: Object.freeze(['student:delete']),
|
||||
isSuperAdmin: true,
|
||||
} as AgentToolContext;
|
||||
|
||||
expect(() => executor.listAvailable(fakeCtx)).toThrow('DENIED');
|
||||
});
|
||||
|
||||
it('Object.create(prototype) without factory is rejected', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
// Even if you get the prototype right, it's not in the WeakSet
|
||||
const fakeCtx2 = Object.create(AgentToolContext.prototype) as AgentToolContext;
|
||||
Object.defineProperties(fakeCtx2, {
|
||||
userId: { value: 1 },
|
||||
username: { value: 'hacker' },
|
||||
permissions: { value: Object.freeze(['student:view', 'student:delete']) },
|
||||
isSuperAdmin: { value: true },
|
||||
});
|
||||
Object.freeze(fakeCtx2);
|
||||
|
||||
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx2);
|
||||
expect(result.status).toBe('denied');
|
||||
});
|
||||
|
||||
it('context does NOT expose ability field', () => {
|
||||
const ctx = superAdminCtx;
|
||||
expect((ctx as Record<string, unknown>).ability).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passing superAdmin-like permissions on non-superAdmin user does NOT grant superAdmin', () => {
|
||||
const ctx = makeCtx({ id: 5, username: 'fake', permissions: ['superadmin:all'], isSuperAdmin: false });
|
||||
expect(ctx.isSuperAdmin).toBe(false);
|
||||
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ctx.permissions,
|
||||
isSuperAdmin: ctx.isSuperAdmin,
|
||||
});
|
||||
expect(ability.can('manage', 'all')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: listAvailable via Executor (not Registry)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('listAvailable via Executor', () => {
|
||||
it('returns ToolDescriptors with name, description, inputSchema — but NOT execute/validate', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const tools: ToolDescriptor[] = executor.listAvailable(studentViewerCtx);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('student_search');
|
||||
expect(tools[0].description).toBeTruthy();
|
||||
expect(tools[0].inputSchema).toBeDefined();
|
||||
expect((tools[0] as Record<string, unknown>).execute).toBeUndefined();
|
||||
expect((tools[0] as Record<string, unknown>).validate).toBeUndefined();
|
||||
expect((tools[0] as Record<string, unknown>).requiredPermission).toBeUndefined();
|
||||
});
|
||||
|
||||
it('super admin sees all tools', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 't1', requiredPermission: 'student:view' }));
|
||||
registry.register(makeTool({ name: 't2', requiredPermission: 'bill:export' }));
|
||||
|
||||
const tools = executor.listAvailable(superAdminCtx);
|
||||
expect(tools).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('hides tool when principal lacks required permission', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const tools = executor.listAvailable(noPermCtx);
|
||||
expect(tools).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters by exact permission code', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
registry.register(makeTool({ name: 'bill_export', requiredPermission: 'bill:export-excel' }));
|
||||
|
||||
const tools = executor.listAvailable(studentViewerCtx);
|
||||
expect(tools).toHaveLength(1);
|
||||
expect(tools[0].name).toBe('student_search');
|
||||
});
|
||||
|
||||
it('descriptors include inputSchema when present', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'get_student',
|
||||
requiredPermission: 'student:view',
|
||||
inputSchema: { type: 'object', properties: { studentId: { type: 'integer' } }, required: ['studentId'], additionalProperties: false },
|
||||
}),
|
||||
);
|
||||
|
||||
const tools = executor.listAvailable(studentViewerCtx);
|
||||
expect(tools[0].inputSchema).toBeDefined();
|
||||
expect(tools[0].inputSchema!.required).toContain('studentId');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rawInput type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('rawInput type guards', () => {
|
||||
it('rejects null input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', null, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('rejects array input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', [1, 2, 3], studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('rejects string input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', 'just a string', studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('rejects number input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', 42, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('validator that throws is caught and returns safe error', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'crash_validate',
|
||||
requiredPermission: 'student:view',
|
||||
validate(): never {
|
||||
throw new Error('INTERNAL: validator crashed with raw SQL');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('crash_validate', { x: 1 }, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
expect(result.error).not.toContain('SQL');
|
||||
expect(result.error).not.toContain('INTERNAL');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error & audit sanitization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Error & audit sanitization', () => {
|
||||
it('tool throw with phone/SQL in message does NOT leak to result', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'leaky',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(): Promise<unknown> {
|
||||
throw new Error('phone=13800138000, idNumber=320106199001011234, SQL: SELECT * FROM students WHERE id=1');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('leaky', {}, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('工具执行失败');
|
||||
expect(result.error).not.toContain('13800138000');
|
||||
expect(result.error).not.toContain('320106');
|
||||
expect(result.error).not.toContain('SQL');
|
||||
expect(result.error).not.toContain('SELECT');
|
||||
});
|
||||
|
||||
it('malicious tool name is sanitized in audit action', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor } = makeExecutor(opLog);
|
||||
|
||||
const result = await executor.execute(
|
||||
'evil\n<script>alert(1)</script>!@#$%^&*()very_long_name_exceeding_64_chars_padding_padding_padding_padding_END',
|
||||
{},
|
||||
studentViewerCtx,
|
||||
);
|
||||
|
||||
expect(result.toolName).not.toContain('\n');
|
||||
expect(result.toolName).not.toContain('<script>');
|
||||
expect(result.toolName).not.toContain('!');
|
||||
expect(result.toolName.length).toBeLessThanOrEqual(64);
|
||||
|
||||
expect(opLog.log).toHaveBeenCalled();
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.action).not.toContain('\n');
|
||||
expect(call.action).not.toContain('<script>');
|
||||
expect(call.action).not.toContain('!');
|
||||
expect(call.action).toContain('denied');
|
||||
});
|
||||
|
||||
it('audit detail never contains exception messages', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'crash',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(): Promise<unknown> {
|
||||
throw new Error('DB error: table students at 10.0.0.1:5432');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await executor.execute('crash', {}, studentViewerCtx);
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.detail).toBe('执行失败');
|
||||
expect(call.detail).not.toContain('DB error');
|
||||
expect(call.detail).not.toContain('10.0.0.1');
|
||||
expect(call.detail).not.toContain('5432');
|
||||
});
|
||||
|
||||
it('unknown tool returns generic denied, not raw tool name detail', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor } = makeExecutor(opLog);
|
||||
|
||||
const result = await executor.execute('hack_tool_with_pii_13800138000', {}, studentViewerCtx);
|
||||
expect(result.error).toBe('未知工具');
|
||||
expect(result.error).not.toContain('13800138000');
|
||||
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.detail).toBe('拒绝访问');
|
||||
expect(call.detail).not.toContain('13800138000');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NotFoundException → not_found
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('NotFoundException → not_found', () => {
|
||||
it('NotFound from tool returns not_found with safe message', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'find_student',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(): Promise<unknown> {
|
||||
throw new NotFoundException('原始内部消息: student 999 not in scope');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('find_student', {}, studentViewerCtx);
|
||||
expect(result.status).toBe('not_found');
|
||||
expect(result.error).toBe('记录不存在或无权访问');
|
||||
expect(result.error).not.toContain('999');
|
||||
expect(result.error).not.toContain('原始内部消息');
|
||||
});
|
||||
|
||||
it('generic Error from tool returns failed with safe message', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'crash',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(): Promise<unknown> {
|
||||
throw new Error('random runtime error');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('crash', {}, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('工具执行失败');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// execute — core behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('execute — unknown tool', () => {
|
||||
it('returns denied for unknown tool name', async () => {
|
||||
const { executor } = makeExecutor();
|
||||
|
||||
const result = await executor.execute('nonexistent', {}, superAdminCtx);
|
||||
expect(result.status).toBe('denied');
|
||||
expect(result.toolName).toBe('nonexistent');
|
||||
expect(result.error).toBe('未知工具');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute — double-check authorization', () => {
|
||||
it('denies even if tool is registered but principal lacks permission', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('student_search', { q: 'test' }, noPermCtx);
|
||||
expect(result.status).toBe('denied');
|
||||
expect(result.error).toBe('权限不足');
|
||||
});
|
||||
|
||||
it('allows execution when principal has required permission', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('student_search', { q: 'test' }, studentViewerCtx);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute — forged input rejection', () => {
|
||||
it('rejects userId in input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute(
|
||||
'student_search',
|
||||
{ userId: 999, q: 'test' },
|
||||
studentViewerCtx,
|
||||
);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('rejects isSuperAdmin in input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute(
|
||||
'student_search',
|
||||
{ isSuperAdmin: true, q: 'test' },
|
||||
studentViewerCtx,
|
||||
);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
|
||||
it('rejects permissions in input', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute(
|
||||
'student_search',
|
||||
{ permissions: ['student:delete'], q: 'test' },
|
||||
studentViewerCtx,
|
||||
);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('输入参数无效');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute — success', () => {
|
||||
it('returns result on success', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'echo',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(input: Record<string, unknown>): Promise<unknown> {
|
||||
return { message: input.text };
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('echo', { text: 'hello' }, studentViewerCtx);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.result).toEqual({ message: 'hello' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute — tool error handling', () => {
|
||||
it('returns failed status with safe message on tool throw', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'crashy',
|
||||
requiredPermission: 'student:view',
|
||||
async execute(): Promise<unknown> {
|
||||
throw new Error('数据库连接失败: connection refused at 10.0.0.1:5432');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('crashy', {}, studentViewerCtx);
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toBe('工具执行失败');
|
||||
expect(result.error).not.toContain('数据库连接失败');
|
||||
expect(result.error).not.toContain('10.0.0.1');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 3: Audit — awaited, best-effort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('audit logging — awaited best-effort', () => {
|
||||
it('logs success with userId/username from context', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
|
||||
|
||||
expect(opLog.log).toHaveBeenCalledTimes(1);
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.userId).toBe(2);
|
||||
expect(call.username).toBe('teacher_zhang');
|
||||
expect(call.module).toBe('AI Agent Tool');
|
||||
expect(call.action).toContain('echo');
|
||||
expect(call.action).toContain('success');
|
||||
});
|
||||
|
||||
it('logs denied with status', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
await executor.execute('student_search', {}, noPermCtx);
|
||||
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.action).toContain('denied');
|
||||
expect(call.detail).toBe('拒绝访问');
|
||||
});
|
||||
|
||||
it('logs failed on validation error', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
|
||||
|
||||
await executor.execute('student_search', { isSuperAdmin: true }, studentViewerCtx);
|
||||
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
expect(call.action).toContain('failed');
|
||||
});
|
||||
|
||||
it('audit detail NEVER contains phone/ID/sensitive fields', async () => {
|
||||
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
await executor.execute('echo', { phone: '13800138000', name: 'test' }, studentViewerCtx);
|
||||
|
||||
const call = opLog.log.mock.calls[0][0];
|
||||
const detail = call.detail as string;
|
||||
expect(detail).not.toContain('13800138000');
|
||||
expect(detail).not.toContain('phone');
|
||||
expect(detail).not.toContain('idNumber');
|
||||
expect(detail).not.toContain('password');
|
||||
});
|
||||
|
||||
it('audit write failure does not break successful tool call', async () => {
|
||||
const opLog = { log: jest.fn().mockRejectedValue(new Error('DB write error')) };
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.result).toEqual({ echoed: { text: 'hi' } });
|
||||
});
|
||||
|
||||
it('execute awaits audit before returning (delayed audit does not drop)', async () => {
|
||||
let auditResolved = false;
|
||||
const opLog = {
|
||||
log: jest.fn().mockImplementation(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
auditResolved = true;
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
}),
|
||||
};
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
// At call time, audit hasn't resolved
|
||||
expect(auditResolved).toBe(false);
|
||||
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
|
||||
// After execute returns, audit IS resolved (awaited)
|
||||
expect(auditResolved).toBe(true);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('audit rejection still resolves execute with correct result', async () => {
|
||||
const opLog = {
|
||||
log: jest.fn().mockImplementation(() => {
|
||||
return new Promise<void>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('audit write failed')), 10);
|
||||
});
|
||||
}),
|
||||
};
|
||||
const { executor, registry } = makeExecutor(opLog);
|
||||
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
|
||||
|
||||
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
|
||||
expect(result.status).toBe('success');
|
||||
expect(result.result).toEqual({ echoed: { text: 'hi' } });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Super admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('super admin', () => {
|
||||
it('super admin can execute any tool regardless of permission', async () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(
|
||||
makeTool({
|
||||
name: 'admin_only',
|
||||
requiredPermission: 'nuclear:launch',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await executor.execute('admin_only', {}, superAdminCtx);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('listAvailable returns all tools for super admin', () => {
|
||||
const { executor, registry } = makeExecutor();
|
||||
registry.register(makeTool({ name: 't1', requiredPermission: 'ghost:action' }));
|
||||
registry.register(makeTool({ name: 't2', requiredPermission: 'custom:code' }));
|
||||
|
||||
const tools = executor.listAvailable(superAdminCtx);
|
||||
expect(tools).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal file
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
|
||||
import { AuthorizationService } from '../authorization';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { AgentToolRegistry } from './agent-tool.registry';
|
||||
import { AgentToolContextFactory } from './agent-tool.types';
|
||||
import type { AgentToolContext, ToolExecutionResult, ToolStatus, ToolDescriptor } from './agent-tool.types';
|
||||
|
||||
/** Safe tool name: alphanumeric + underscore, max 64 chars. */
|
||||
const TOOL_NAME_RE = /^[a-zA-Z0-9_]+$/;
|
||||
const TOOL_NAME_MAX_LEN = 64;
|
||||
|
||||
/** Safe user-facing messages that never leak internals. */
|
||||
const SAFE_MESSAGES = {
|
||||
unknownTool: '未知工具',
|
||||
permissionDenied: '权限不足',
|
||||
invalidInput: '输入参数无效',
|
||||
executionFailed: '工具执行失败',
|
||||
notFound: '记录不存在或无权访问',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Executes Agent Tools with double-check authorization, input validation,
|
||||
* context trust validation, and audit logging.
|
||||
*
|
||||
* ## Security guarantees
|
||||
*
|
||||
* 1. Context trust is validated at runtime via
|
||||
* {@link AgentToolContextFactory.assertTrusted} — forged/plain-object
|
||||
* contexts are rejected.
|
||||
* 2. The ability is constructed fresh from the principal in the context
|
||||
* — callers cannot pre-forge it.
|
||||
* 3. Permission is checked AGAIN at execute time (not just at list time).
|
||||
* 4. Unknown tools are rejected with a generic message, and the tool name
|
||||
* is sanitized in audit logs.
|
||||
* 5. `rawInput` is `unknown` — null, arrays, and strings are caught before
|
||||
* validation.
|
||||
* 6. All tool & validator exceptions are caught and mapped to safe messages.
|
||||
* 7. Audit logs never include raw input, stack traces, or internal error text.
|
||||
* 8. Audit log is awaited best-effort — failure does NOT fail the tool call.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgentToolExecutor {
|
||||
constructor(
|
||||
private readonly registry: AgentToolRegistry,
|
||||
private readonly abilityFactory: CaslAbilityFactory,
|
||||
private readonly authz: AuthorizationService,
|
||||
private readonly opLog: OperationLogsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* List tools available to the given context.
|
||||
*
|
||||
* Returns read-only {@link ToolDescriptor}s — never exposes
|
||||
* `execute`, `validate`, or `requiredPermission`.
|
||||
*
|
||||
* This is the ONLY public entry point for tool discovery.
|
||||
* SDK consumers MUST use this instead of direct Registry access.
|
||||
*
|
||||
* @param context — trusted context from
|
||||
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
|
||||
*/
|
||||
listAvailable(context: AgentToolContext): ToolDescriptor[] {
|
||||
AgentToolContextFactory.assertTrusted(context);
|
||||
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
permissions: context.permissions,
|
||||
isSuperAdmin: context.isSuperAdmin,
|
||||
});
|
||||
|
||||
return this.registry
|
||||
.listAvailableInternal(ability)
|
||||
.map(({ name, description, inputSchema }) => ({
|
||||
name,
|
||||
description,
|
||||
...(inputSchema ? { inputSchema } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name.
|
||||
*
|
||||
* @param name — tool name (e.g. "search_students"). Must pass sanitization.
|
||||
* @param rawInput — raw input from the model (may be any JSON value).
|
||||
* @param context — trusted context from
|
||||
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
|
||||
*/
|
||||
async execute(
|
||||
name: string,
|
||||
rawInput: unknown,
|
||||
context: AgentToolContext,
|
||||
): Promise<ToolExecutionResult> {
|
||||
// 0. Context trust validation — must be first
|
||||
try {
|
||||
AgentToolContextFactory.assertTrusted(context);
|
||||
} catch {
|
||||
return { status: 'denied', toolName: '_denied', error: SAFE_MESSAGES.permissionDenied };
|
||||
}
|
||||
|
||||
// 1. Sanitize tool name — model-controlled input
|
||||
const safeName = this.sanitizeName(name);
|
||||
|
||||
const tool = this.registry.getForExecution(name);
|
||||
if (!tool) {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'denied',
|
||||
undefined,
|
||||
SAFE_MESSAGES.unknownTool,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Build ability from principal fields — never trust a pre-built one
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
permissions: context.permissions,
|
||||
isSuperAdmin: context.isSuperAdmin,
|
||||
});
|
||||
|
||||
// 3. Double-check authorization at execute time
|
||||
if (!this.authz.canPermission(ability, tool.requiredPermission)) {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'denied',
|
||||
undefined,
|
||||
SAFE_MESSAGES.permissionDenied,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Guard: rawInput must be a plain object
|
||||
if (rawInput === null || Array.isArray(rawInput) || typeof rawInput !== 'object') {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'failed',
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Validate and parse input — validator exceptions are caught
|
||||
let parsed: { ok: true; value: unknown } | { ok: false };
|
||||
try {
|
||||
parsed = tool.validate(rawInput as Record<string, unknown>);
|
||||
} catch {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'failed',
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
);
|
||||
}
|
||||
if (!parsed.ok) {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'failed',
|
||||
undefined,
|
||||
SAFE_MESSAGES.invalidInput,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Execute
|
||||
try {
|
||||
const result = await tool.execute(parsed.value, context);
|
||||
return this.auditAndReturn(safeName, 'success', result, undefined, context);
|
||||
} catch (err: unknown) {
|
||||
// NotFoundException → not_found with safe message
|
||||
if (err instanceof NotFoundException) {
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'not_found',
|
||||
undefined,
|
||||
SAFE_MESSAGES.notFound,
|
||||
context,
|
||||
);
|
||||
}
|
||||
// All other errors → generic failed message
|
||||
return this.auditAndReturn(
|
||||
safeName,
|
||||
'failed',
|
||||
undefined,
|
||||
SAFE_MESSAGES.executionFailed,
|
||||
context,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a tool name from model input.
|
||||
*
|
||||
* Only allows `[a-zA-Z0-9_]`, max {@link TOOL_NAME_MAX_LEN} chars.
|
||||
* Returns the sanitized name or a safe fallback.
|
||||
*/
|
||||
private sanitizeName(name: string): string {
|
||||
if (typeof name !== 'string') return '_invalid';
|
||||
const trimmed = name.slice(0, TOOL_NAME_MAX_LEN);
|
||||
if (TOOL_NAME_RE.test(trimmed)) return trimmed;
|
||||
// Replace unsafe chars with underscore
|
||||
return trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build result + best-effort awaited audit log.
|
||||
* Audit write failure is caught and never propagated — it must not
|
||||
* turn a successful data read into a failure.
|
||||
*/
|
||||
private async auditAndReturn(
|
||||
toolName: string,
|
||||
status: ToolStatus,
|
||||
result: unknown,
|
||||
error: string | undefined,
|
||||
context: AgentToolContext,
|
||||
): Promise<ToolExecutionResult> {
|
||||
// Await audit (best-effort — failure is silently swallowed)
|
||||
try {
|
||||
await this.opLog.log({
|
||||
userId: context.userId,
|
||||
username: context.username,
|
||||
module: 'AI Agent Tool',
|
||||
action: `${toolName} [${status}]`,
|
||||
detail: this.buildAuditDetail(status),
|
||||
status,
|
||||
});
|
||||
} catch {
|
||||
// Swallow — audit failure must not break the tool call
|
||||
}
|
||||
|
||||
return { status, toolName, result, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a safe audit detail string.
|
||||
* NEVER includes raw input, exception messages, phone numbers, or other PII.
|
||||
* Only writes safe category labels.
|
||||
*/
|
||||
private buildAuditDetail(status: ToolStatus): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '执行成功';
|
||||
case 'denied':
|
||||
return '拒绝访问';
|
||||
case 'not_found':
|
||||
return '记录不存在或无权访问';
|
||||
default:
|
||||
return '执行失败';
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal file
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CaslAction } from '../authorization/casl.constants';
|
||||
import type { AppAbility } from '../authorization';
|
||||
import type { ToolDef } from './agent-tool.types';
|
||||
|
||||
/**
|
||||
* Internal tool registry — NOT exported from the module.
|
||||
*
|
||||
* Holds all registered Agent Tools. Lookups are delegated from
|
||||
* {@link AgentToolExecutor}, which handles authorization, context
|
||||
* validation, and audit logging.
|
||||
*
|
||||
* SDK consumers MUST NOT access this directly — use
|
||||
* {@link AgentToolExecutor.listAvailable} and
|
||||
* {@link AgentToolExecutor.execute} instead.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgentToolRegistry {
|
||||
private readonly tools: ToolDef[] = [];
|
||||
|
||||
/** Register a tool (called once at module init). */
|
||||
register(tool: ToolDef): void {
|
||||
const idx = this.tools.findIndex((t) => t.name === tool.name);
|
||||
if (idx >= 0) {
|
||||
this.tools[idx] = tool;
|
||||
} else {
|
||||
this.tools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return tools whose required permission the given ability satisfies.
|
||||
* The ability is built by the caller (Executor) — this is a pure
|
||||
* filter, not an authorization decision.
|
||||
*/
|
||||
listAvailableInternal(ability: AppAbility): ToolDef[] {
|
||||
return this.tools.filter((tool) => {
|
||||
// Super admin ability has manage all — passes everything
|
||||
if (ability.can(CaslAction.Manage, 'all')) return true;
|
||||
// Exact permission-code check via CASL Access
|
||||
return ability.can(CaslAction.Access, `PermissionCode:${tool.requiredPermission}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an internal {@link ToolDef} by name.
|
||||
* Returns `undefined` if not found.
|
||||
*/
|
||||
getForExecution(name: string): ToolDef | undefined {
|
||||
return this.tools.find((t) => t.name === name);
|
||||
}
|
||||
}
|
||||
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal file
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentToolContext — trusted server-side principal (NO ability)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Module-private brand and trusted set for runtime forgery resistance
|
||||
const trustedContexts = new WeakSet<AgentToolContext>();
|
||||
const CONTEXT_BRAND = Symbol('AgentToolContext');
|
||||
|
||||
/**
|
||||
* Execution context for Agent Tool invocations — branded to prevent
|
||||
* forgery. Only {@link AgentToolContextFactory} can create trusted
|
||||
* instances; {@link AgentToolExecutor} enforces this at runtime via
|
||||
* {@link AgentToolContextFactory.assertTrusted}.
|
||||
*
|
||||
* All fields come from the trusted server-side authentication layer.
|
||||
* The CASL ability is deliberately OMITTED — callers cannot inject a
|
||||
* pre-forged ability.
|
||||
*/
|
||||
export class AgentToolContext {
|
||||
/** The authenticated user's numeric ID. */
|
||||
readonly userId!: number;
|
||||
/** The authenticated user's login name (for audit). */
|
||||
readonly username!: string;
|
||||
/**
|
||||
* Flat list of `resource:action` permission codes.
|
||||
* Frozen at creation — downstream code cannot mutate it.
|
||||
*/
|
||||
readonly permissions!: readonly string[];
|
||||
/** Whether the user has a super-admin role. */
|
||||
readonly isSuperAdmin!: boolean;
|
||||
|
||||
/** @internal Module-private brand — set only by the Factory. */
|
||||
private readonly _brand = CONTEXT_BRAND;
|
||||
|
||||
private constructor() {
|
||||
// Construction is only via AgentToolContextFactory
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a trusted {@link AgentToolContext} from the authenticated
|
||||
* user record populated by the JWT strategy.
|
||||
*
|
||||
* This is the ONLY way to create an AgentToolContext — never construct
|
||||
* it by hand. The returned context is frozen and registered in an
|
||||
* internal WeakSet; {@link assertTrusted} rejects any context not
|
||||
* created through this factory.
|
||||
*/
|
||||
export class AgentToolContextFactory {
|
||||
/**
|
||||
* Build a frozen, branded context from an authenticated user.
|
||||
*
|
||||
* @param user — the user record placed on the request by JWT auth.
|
||||
*/
|
||||
static fromAuthenticatedUser(user: AuthenticatedUser): AgentToolContext {
|
||||
const ctx = Object.create(AgentToolContext.prototype) as AgentToolContext;
|
||||
Object.defineProperties(ctx, {
|
||||
userId: { value: user.id, enumerable: true, writable: false, configurable: false },
|
||||
username: { value: user.username, enumerable: true, writable: false, configurable: false },
|
||||
permissions: {
|
||||
value: Object.freeze([...user.permissions]),
|
||||
enumerable: true,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
},
|
||||
isSuperAdmin: { value: user.isSuperAdmin, enumerable: true, writable: false, configurable: false },
|
||||
_brand: { value: CONTEXT_BRAND, enumerable: false, writable: false, configurable: false },
|
||||
});
|
||||
Object.freeze(ctx);
|
||||
trustedContexts.add(ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime check: reject forged/plain-object contexts.
|
||||
*
|
||||
* Called at the entry of {@link AgentToolExecutor.execute} and
|
||||
* {@link AgentToolExecutor.listAvailable}. Throws if the argument
|
||||
* was not created by {@link fromAuthenticatedUser}.
|
||||
*/
|
||||
static assertTrusted(context: unknown): asserts context is AgentToolContext {
|
||||
if (
|
||||
!(context instanceof AgentToolContext) ||
|
||||
!trustedContexts.has(context)
|
||||
) {
|
||||
throw new Error('DENIED: untrusted execution context');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDescriptor — public, non-executable tool surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A read-only descriptor of an agent tool returned to SDK consumers.
|
||||
*
|
||||
* Does NOT expose `execute`, `validate`, or `requiredPermission` —
|
||||
* callers must go through {@link AgentToolExecutor} for double-check
|
||||
* authorization, input validation, and audit logging.
|
||||
*/
|
||||
export interface ToolDescriptor {
|
||||
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
|
||||
readonly name: string;
|
||||
/** Human-readable description for the model. */
|
||||
readonly description: string;
|
||||
/**
|
||||
* Optional provider-neutral JSON Schema-like input description.
|
||||
* Never exposes execution internals or permission details.
|
||||
*/
|
||||
readonly inputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToolDef — internal tool definition (NOT for SDK consumers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of input validation — either success with parsed input,
|
||||
* or an error message.
|
||||
*/
|
||||
export type ToolInputResult<T> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: string };
|
||||
|
||||
/**
|
||||
* A single Agent Tool definition — internal use only.
|
||||
*
|
||||
* SDK consumers MUST receive a {@link ToolDescriptor}, never a `ToolDef`.
|
||||
* Tool execution always goes through {@link AgentToolExecutor}.
|
||||
*
|
||||
* @typeParam TInput — the parsed & validated input shape the `execute`
|
||||
* function receives.
|
||||
*/
|
||||
export interface ToolDef<TInput = unknown> {
|
||||
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
|
||||
readonly name: string;
|
||||
/** Human-readable description for the model. */
|
||||
readonly description: string;
|
||||
/**
|
||||
* The exact `resource:action` permission code required to use this tool.
|
||||
* Checked via {@link AuthorizationService.canPermission}.
|
||||
*/
|
||||
readonly requiredPermission: string;
|
||||
/**
|
||||
* Optional provider-neutral JSON Schema-like input description.
|
||||
*/
|
||||
readonly inputSchema?: Record<string, unknown>;
|
||||
/**
|
||||
* Validate and parse raw input from the model.
|
||||
* Reject unknown/sensitive fields (userId, permissions, isSuperAdmin, …).
|
||||
*/
|
||||
validate(input: Record<string, unknown>): ToolInputResult<TInput>;
|
||||
/**
|
||||
* Execute the tool with parsed input and the trusted context.
|
||||
* MUST NOT trust `context` to come from input.
|
||||
*/
|
||||
execute(input: TInput, context: AgentToolContext): Promise<unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool execution status (for audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ToolStatus = 'success' | 'denied' | 'failed' | 'not_found';
|
||||
|
||||
/**
|
||||
* Result returned by {@link AgentToolExecutor.execute}.
|
||||
*/
|
||||
export interface ToolExecutionResult {
|
||||
readonly status: ToolStatus;
|
||||
readonly toolName: string;
|
||||
/** Set on success; `undefined` on denied / failed / not_found. */
|
||||
readonly result?: unknown;
|
||||
/** Set on denied / failed / not_found; `undefined` on success.
|
||||
* Always a safe, human-readable message — never raw exception text. */
|
||||
readonly error?: string;
|
||||
}
|
||||
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal file
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Module, OnModuleInit } from '@nestjs/common';
|
||||
import { StudentsModule } from '../students/students.module';
|
||||
import { AgentToolRegistry } from './agent-tool.registry';
|
||||
import { AgentToolExecutor } from './agent-tool.executor';
|
||||
import { SearchStudentsTool } from './tools/search-students.tool';
|
||||
import { GetStudentBasicTool } from './tools/get-student-basic.tool';
|
||||
|
||||
/**
|
||||
* Agent Tools feature module.
|
||||
*
|
||||
* Provides a provider-neutral tool executor for LLM agent frameworks.
|
||||
* SDK consumers interact ONLY with {@link AgentToolExecutor}.
|
||||
*
|
||||
* `AgentToolRegistry` is an internal provider — it is NOT exported from
|
||||
* this module. All tool listing and execution goes through the executor,
|
||||
* which enforces double-check authorization, audit logging, and context
|
||||
* trust validation.
|
||||
*
|
||||
* Imports `StudentsModule` for student data access and relies on the
|
||||
* globally available `AuthorizationModule` and `OperationLogsModule`.
|
||||
*/
|
||||
@Module({
|
||||
imports: [StudentsModule],
|
||||
providers: [
|
||||
AgentToolRegistry,
|
||||
AgentToolExecutor,
|
||||
SearchStudentsTool,
|
||||
GetStudentBasicTool,
|
||||
],
|
||||
exports: [AgentToolExecutor],
|
||||
})
|
||||
export class AgentToolsModule implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly registry: AgentToolRegistry,
|
||||
private readonly searchTool: SearchStudentsTool,
|
||||
private readonly getTool: GetStudentBasicTool,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
this.registry.register(this.searchTool);
|
||||
this.registry.register(this.getTool);
|
||||
}
|
||||
}
|
||||
4
apps/server/src/agent-tools/index.ts
Normal file
4
apps/server/src/agent-tools/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AgentToolsModule } from './agent-tools.module';
|
||||
export { AgentToolExecutor } from './agent-tool.executor';
|
||||
export { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
|
||||
export type { ToolDescriptor, ToolExecutionResult, ToolStatus } from './agent-tool.types';
|
||||
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { GetStudentBasicTool } from './get-student-basic.tool';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
|
||||
import { AgentToolContextFactory } from '../agent-tool.types';
|
||||
import type { AgentToolContext } from '../agent-tool.types';
|
||||
import type { AuthenticatedUser } from '../../authorization';
|
||||
|
||||
const abilityFactory = new CaslAbilityFactory();
|
||||
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
|
||||
|
||||
function makeCtx(overrides: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: overrides.id,
|
||||
username: overrides.username,
|
||||
permissions: overrides.permissions ?? [],
|
||||
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
||||
roles: overrides.roles ?? [],
|
||||
};
|
||||
return AgentToolContextFactory.fromAuthenticatedUser(user);
|
||||
}
|
||||
|
||||
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
|
||||
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
|
||||
const classEditorCtx = makeCtx({
|
||||
id: 4,
|
||||
username: 'class_editor',
|
||||
permissions: ['student:view', 'class:edit'],
|
||||
});
|
||||
|
||||
function makeTool(svcOverride?: { agentGetStudentBasic: jest.Mock }): GetStudentBasicTool {
|
||||
const svc = svcOverride ?? { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
return new GetStudentBasicTool(svc as never, scopeFactory);
|
||||
}
|
||||
|
||||
const basicOutput = {
|
||||
id: 1,
|
||||
name: '张三',
|
||||
studentNo: 'S001',
|
||||
gender: '男',
|
||||
status: 'active',
|
||||
organizationId: 10,
|
||||
organizationName: '杭州校区',
|
||||
classIds: [5],
|
||||
};
|
||||
|
||||
describe('GetStudentBasicTool', () => {
|
||||
it('has name "get_student_basic"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.name).toBe('get_student_basic');
|
||||
});
|
||||
|
||||
it('requires permission "student:view"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.requiredPermission).toBe('student:view');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('validate', () => {
|
||||
it('accepts valid studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.studentId).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects missing studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('studentId');
|
||||
});
|
||||
|
||||
it('rejects non-integer studentId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 'abc' });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects extra unknown fields', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1, extraField: 'hack' });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('extraField');
|
||||
});
|
||||
|
||||
it('rejects userId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ studentId: 1, userId: 999 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('userId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-2: Scope construction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-2: scope', () => {
|
||||
it('super admin uses manageAll scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, superAdminCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'manageAll' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('non-admin uses teacher scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, studentViewerCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'teacher', userId: 2 },
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('class:edit uses manageAll scope', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ studentId: 1 }, classEditorCtx);
|
||||
|
||||
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
|
||||
{ type: 'manageAll' },
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-1: NotFoundException for null result
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-1: NotFoundException', () => {
|
||||
it('null from service throws NotFoundException (not returned as success)', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('service NotFound message is "记录不存在或无权访问"', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
|
||||
'记录不存在或无权访问',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Execute — happy path
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns formatted student data', async () => {
|
||||
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
const result = await tool.execute({ studentId: 1 }, superAdminCtx);
|
||||
expect(result).toEqual(basicOutput);
|
||||
const keys = Object.keys(result as Record<string, unknown>);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal file
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { StudentsService } from '../../students/students.service';
|
||||
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
|
||||
interface GetStudentBasicInput {
|
||||
studentId: number;
|
||||
}
|
||||
|
||||
/** Forbidden input keys — if the model sends these, validation fails. */
|
||||
const FORBIDDEN_INPUT_KEYS = new Set([
|
||||
'userId',
|
||||
'isSuperAdmin',
|
||||
'permissions',
|
||||
'roles',
|
||||
'ability',
|
||||
'user',
|
||||
'password',
|
||||
'token',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class GetStudentBasicTool implements ToolDef<GetStudentBasicInput> {
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
studentId: {
|
||||
type: 'integer',
|
||||
description: '学生ID',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['studentId'],
|
||||
additionalProperties: false,
|
||||
};
|
||||
readonly name = 'get_student_basic';
|
||||
readonly description = '获取单个学生基本信息。仅返回基础公开字段。';
|
||||
readonly requiredPermission = 'student:view';
|
||||
|
||||
constructor(
|
||||
private readonly studentsService: StudentsService,
|
||||
private readonly scopeFactory: StudentAccessScopeFactory,
|
||||
) {}
|
||||
|
||||
validate(input: Record<string, unknown>): ToolInputResult<GetStudentBasicInput> {
|
||||
for (const key of Object.keys(input)) {
|
||||
if (FORBIDDEN_INPUT_KEYS.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (input.studentId === undefined) {
|
||||
return { ok: false, error: '缺少必填字段: studentId' };
|
||||
}
|
||||
|
||||
const studentId = Number(input.studentId);
|
||||
if (!Number.isInteger(studentId) || studentId <= 0) {
|
||||
return { ok: false, error: 'studentId 必须是正整数' };
|
||||
}
|
||||
|
||||
// Reject unexpected keys
|
||||
const allowedKeys = new Set(['studentId']);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: { studentId } };
|
||||
}
|
||||
|
||||
async execute(input: GetStudentBasicInput, context: AgentToolContext): Promise<unknown> {
|
||||
const scope = this.scopeFactory.buildScope(context);
|
||||
const result = await this.studentsService.agentGetStudentBasic(scope, input.studentId);
|
||||
|
||||
if (result === null) {
|
||||
throw new NotFoundException('记录不存在或无权访问');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal file
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { SearchStudentsTool } from './search-students.tool';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
|
||||
import { AgentToolContextFactory } from '../agent-tool.types';
|
||||
import type { AgentToolContext } from '../agent-tool.types';
|
||||
import type { AuthenticatedUser } from '../../authorization';
|
||||
|
||||
const abilityFactory = new CaslAbilityFactory();
|
||||
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
|
||||
|
||||
function makeCtx(
|
||||
overrides: Partial<AuthenticatedUser> & { id: number; username: string },
|
||||
): AgentToolContext {
|
||||
const user: AuthenticatedUser = {
|
||||
id: overrides.id,
|
||||
username: overrides.username,
|
||||
permissions: overrides.permissions ?? [],
|
||||
isSuperAdmin: overrides.isSuperAdmin ?? false,
|
||||
roles: overrides.roles ?? [],
|
||||
};
|
||||
return AgentToolContextFactory.fromAuthenticatedUser(user);
|
||||
}
|
||||
|
||||
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
|
||||
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
|
||||
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
|
||||
const classEditorCtx = makeCtx({
|
||||
id: 4,
|
||||
username: 'class_editor',
|
||||
permissions: ['student:view', 'class:edit'],
|
||||
});
|
||||
|
||||
function makeTool(svcOverride?: { agentSearchStudents: jest.Mock }): SearchStudentsTool {
|
||||
const svc = svcOverride ?? { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
||||
return new SearchStudentsTool(svc as never, scopeFactory);
|
||||
}
|
||||
|
||||
describe('SearchStudentsTool', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// Tool metadata
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('has name "search_students"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.name).toBe('search_students');
|
||||
});
|
||||
|
||||
it('requires permission "student:view"', () => {
|
||||
const tool = makeTool();
|
||||
expect(tool.requiredPermission).toBe('student:view');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input validation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('validate', () => {
|
||||
it('accepts valid input with keyword', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ keyword: '张三' });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.keyword).toBe('张三');
|
||||
});
|
||||
|
||||
it('accepts valid input with classId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ classId: 5 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.classId).toBe(5);
|
||||
});
|
||||
|
||||
it('accepts valid input with organizationId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ organizationId: 10 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.organizationId).toBe(10);
|
||||
});
|
||||
|
||||
it('accepts valid input with limit', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ limit: 30 });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.value.limit).toBe(30);
|
||||
});
|
||||
|
||||
it('rejects userId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ userId: 999 });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('userId');
|
||||
});
|
||||
|
||||
it('rejects isSuperAdmin', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ isSuperAdmin: true });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('isSuperAdmin');
|
||||
});
|
||||
|
||||
it('rejects permissions', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ permissions: ['student:delete'] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('permissions');
|
||||
});
|
||||
|
||||
it('rejects roles', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ roles: ['admin'] });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('roles');
|
||||
});
|
||||
|
||||
it('rejects ability', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ ability: {} });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('ability');
|
||||
});
|
||||
|
||||
it('rejects unknown fields to match additionalProperties false', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ debug: true });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects limit above the advertised maximum', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ limit: 51 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-integer classId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ classId: 'abc' });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-integer organizationId', () => {
|
||||
const tool = makeTool();
|
||||
const result = tool.validate({ organizationId: 1.5 });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-2: Scope construction via StudentAccessScopeFactory
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-2: scope construction', () => {
|
||||
it('super admin uses manageAll scope', async () => {
|
||||
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({}, superAdminCtx);
|
||||
|
||||
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
|
||||
});
|
||||
|
||||
it('non-admin uses teacher scope with userId', async () => {
|
||||
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({ keyword: 'test' }, studentViewerCtx);
|
||||
|
||||
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith(
|
||||
{ type: 'teacher', userId: 2 },
|
||||
{ keyword: 'test' },
|
||||
);
|
||||
});
|
||||
|
||||
it('class:edit permission grants manageAll scope (not teacher)', async () => {
|
||||
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
|
||||
const tool = makeTool(mockSvc);
|
||||
|
||||
await tool.execute({}, classEditorCtx);
|
||||
|
||||
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal file
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { StudentsService } from '../../students/students.service';
|
||||
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
|
||||
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
|
||||
|
||||
/** Whitelisted input shape for search_students. */
|
||||
interface SearchStudentsInput {
|
||||
keyword?: string;
|
||||
classId?: number;
|
||||
organizationId?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/** Forbidden input keys — if the model sends these, validation fails. */
|
||||
const FORBIDDEN_INPUT_KEYS = new Set([
|
||||
'userId',
|
||||
'isSuperAdmin',
|
||||
'permissions',
|
||||
'roles',
|
||||
'ability',
|
||||
'user',
|
||||
'password',
|
||||
'token',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class SearchStudentsTool implements ToolDef<SearchStudentsInput> {
|
||||
readonly name = 'search_students';
|
||||
readonly inputSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
keyword: {
|
||||
type: 'string',
|
||||
description: '搜索关键词(姓名/学号)',
|
||||
maxLength: 100,
|
||||
},
|
||||
classId: {
|
||||
type: 'integer',
|
||||
description: '班级ID',
|
||||
minimum: 1,
|
||||
},
|
||||
organizationId: {
|
||||
type: 'integer',
|
||||
description: '校区ID',
|
||||
minimum: 1,
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
description: '返回条数上限',
|
||||
minimum: 1,
|
||||
maximum: 50,
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
};
|
||||
readonly description = '搜索学生,支持关键词、班级、校区筛选。仅返回基础公开字段。';
|
||||
readonly requiredPermission = 'student:view';
|
||||
|
||||
constructor(
|
||||
private readonly studentsService: StudentsService,
|
||||
private readonly scopeFactory: StudentAccessScopeFactory,
|
||||
) {}
|
||||
|
||||
validate(input: Record<string, unknown>): ToolInputResult<SearchStudentsInput> {
|
||||
// Reject forbidden keys
|
||||
for (const key of Object.keys(input)) {
|
||||
if (FORBIDDEN_INPUT_KEYS.has(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `不允许的输入字段: ${key}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const allowedKeys = new Set(['keyword', 'classId', 'organizationId', 'limit']);
|
||||
for (const key of Object.keys(input)) {
|
||||
if (!allowedKeys.has(key)) {
|
||||
return { ok: false, error: `不允许的输入字段: ${key}` };
|
||||
}
|
||||
}
|
||||
|
||||
const result: SearchStudentsInput = {};
|
||||
|
||||
if (input.keyword !== undefined) {
|
||||
if (typeof input.keyword !== 'string' || input.keyword.length > 100) {
|
||||
return { ok: false, error: 'keyword 必须是字符串且长度不超过100' };
|
||||
}
|
||||
result.keyword = input.keyword;
|
||||
}
|
||||
|
||||
if (input.classId !== undefined) {
|
||||
const id = Number(input.classId);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { ok: false, error: 'classId 必须是正整数' };
|
||||
}
|
||||
result.classId = id;
|
||||
}
|
||||
|
||||
if (input.organizationId !== undefined) {
|
||||
const id = Number(input.organizationId);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return { ok: false, error: 'organizationId 必须是正整数' };
|
||||
}
|
||||
result.organizationId = id;
|
||||
}
|
||||
|
||||
if (input.limit !== undefined) {
|
||||
const limit = Number(input.limit);
|
||||
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
||||
return { ok: false, error: 'limit 必须是 1 到 50 的整数' };
|
||||
}
|
||||
result.limit = limit;
|
||||
}
|
||||
|
||||
return { ok: true, value: result };
|
||||
}
|
||||
|
||||
async execute(input: SearchStudentsInput, context: AgentToolContext): Promise<unknown> {
|
||||
const scope = this.scopeFactory.buildScope(context);
|
||||
return this.studentsService.agentSearchStudents(scope, input);
|
||||
}
|
||||
}
|
||||
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal file
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AiConfigController } from './ai-config.controller';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { AiProvider } from './ai-config.entity';
|
||||
|
||||
describe('AiConfigController', () => {
|
||||
let controller: AiConfigController;
|
||||
let service: jest.Mocked<Pick<AiConfigService, 'getConfig' | 'saveConfig' | 'testConnection' | 'clearKey'>>;
|
||||
let opLog: jest.Mocked<Pick<OperationLogsService, 'log'>>;
|
||||
|
||||
const mockConfig = {
|
||||
id: 1,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
hasApiKey: true,
|
||||
hasDatabaseKey: true,
|
||||
maskedApiKey: '••••1234',
|
||||
keySource: 'database' as const,
|
||||
defaultModel: 'gpt-4',
|
||||
enabled: true,
|
||||
timeoutMs: 30000,
|
||||
verified: true,
|
||||
lastTestedAt: '2024-01-01T00:00:00.000Z',
|
||||
lastTestLatencyMs: 250,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const mockReq = {
|
||||
user: { id: 1, username: 'admin' },
|
||||
headers: { 'user-agent': 'test', 'x-forwarded-for': '1.2.3.4' },
|
||||
connection: { remoteAddress: '1.2.3.4' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
service = {
|
||||
getConfig: jest.fn(),
|
||||
saveConfig: jest.fn(),
|
||||
testConnection: jest.fn(),
|
||||
clearKey: jest.fn(),
|
||||
};
|
||||
|
||||
opLog = {
|
||||
log: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AiConfigController],
|
||||
providers: [
|
||||
{ provide: AiConfigService, useValue: service },
|
||||
{ provide: OperationLogsService, useValue: opLog },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AiConfigController>(AiConfigController);
|
||||
});
|
||||
|
||||
// ── GET /ai/config ────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /ai/config', () => {
|
||||
it('returns config with success wrapper', async () => {
|
||||
service.getConfig.mockResolvedValue(mockConfig);
|
||||
const result = await controller.getConfig();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
it('calls service.getConfig', async () => {
|
||||
service.getConfig.mockResolvedValue(mockConfig);
|
||||
await controller.getConfig();
|
||||
expect(service.getConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /ai/config ────────────────────────────────────────────────────
|
||||
|
||||
describe('PUT /ai/config', () => {
|
||||
const saveDto = {
|
||||
provider: AiProvider.OPENAI,
|
||||
apiKey: 'sk-new-key',
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it('saves config and logs operation', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
const result = await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'save',
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
detail: expect.stringContaining('provider=OPENAI'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain apiKey', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('sk-new-key');
|
||||
expect(logCall.detail).not.toContain(saveDto.apiKey);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain full baseUrl', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(saveDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('api.openai.com/v1');
|
||||
// Only hostname should be present
|
||||
expect(logCall.detail).toContain('host=api.openai.com');
|
||||
});
|
||||
|
||||
it('operation log detail logs model as configured/not-set not raw value', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(
|
||||
{ provider: AiProvider.OPENAI, defaultModel: 'gpt-4' },
|
||||
mockReq,
|
||||
);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).toContain('model=configured');
|
||||
expect(logCall.detail).not.toContain('gpt-4');
|
||||
});
|
||||
|
||||
it('operation log detail logs model=not-set when no defaultModel', async () => {
|
||||
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1' } as any;
|
||||
service.saveConfig.mockResolvedValue(saved);
|
||||
|
||||
await controller.saveConfig(
|
||||
{ provider: AiProvider.OPENAI },
|
||||
mockReq,
|
||||
);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).toContain('model=not-set');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /ai/config/test ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /ai/config/test', () => {
|
||||
const testDto = { provider: AiProvider.OPENAI };
|
||||
const testResult = {
|
||||
success: true,
|
||||
latencyMs: 200,
|
||||
modelCount: 5,
|
||||
modelAvailable: true,
|
||||
testedAt: '2024-01-01T00:00:00.000Z',
|
||||
message: '连接成功',
|
||||
};
|
||||
|
||||
it('returns test result and logs operation', async () => {
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
const result = await controller.testConnection(testDto, mockReq);
|
||||
|
||||
expect(result).toEqual(testResult);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'test',
|
||||
detail: expect.stringContaining('success=true'),
|
||||
status: 'success',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs status=failure on test failure', async () => {
|
||||
const failResult = { ...testResult, success: false, message: '认证失败' };
|
||||
service.testConnection.mockResolvedValue(failResult);
|
||||
|
||||
await controller.testConnection(testDto, mockReq);
|
||||
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'failure',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain sensitive info', async () => {
|
||||
const dtoWithKey = { provider: AiProvider.OPENAI, apiKey: 'sk-secret-key' };
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
await controller.testConnection(dtoWithKey, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('sk-secret-key');
|
||||
expect(logCall.detail).not.toContain('Authorization');
|
||||
});
|
||||
|
||||
it('operation log detail does NOT contain modelCount', async () => {
|
||||
service.testConnection.mockResolvedValue(testResult);
|
||||
|
||||
await controller.testConnection(testDto, mockReq);
|
||||
|
||||
const logCall = opLog.log.mock.calls[0][0];
|
||||
expect(logCall.detail).not.toContain('modelCount');
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /ai/config/clear-key ─────────────────────────────────────────
|
||||
|
||||
describe('POST /ai/config/clear-key', () => {
|
||||
it('clears key and logs operation', async () => {
|
||||
service.clearKey.mockResolvedValue({
|
||||
...mockConfig,
|
||||
hasApiKey: false,
|
||||
hasDatabaseKey: false,
|
||||
maskedApiKey: null,
|
||||
keySource: 'none',
|
||||
});
|
||||
|
||||
const result = await controller.clearKey(mockReq);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data.keySource).toBe('none');
|
||||
expect(result.data.hasDatabaseKey).toBe(false);
|
||||
expect(opLog.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
module: 'ai-config',
|
||||
action: 'clear-key',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Permission decorators ─────────────────────────────────────────────
|
||||
|
||||
describe('route permissions', () => {
|
||||
it('GET /ai/config requires ai:config:read', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.getConfig,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:read');
|
||||
});
|
||||
|
||||
it('PUT /ai/config requires ai:config:write', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:write');
|
||||
});
|
||||
|
||||
it('POST /ai/config/test requires ai:config:test', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.testConnection,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:test');
|
||||
});
|
||||
|
||||
it('POST /ai/config/clear-key requires ai:config:write', () => {
|
||||
const permissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.clearKey,
|
||||
);
|
||||
expect(permissions).toContain('ai:config:write');
|
||||
});
|
||||
|
||||
it('read permission cannot write', () => {
|
||||
const getPermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.getConfig,
|
||||
);
|
||||
const savePermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
expect(getPermissions).not.toEqual(savePermissions);
|
||||
});
|
||||
|
||||
it('write and test permissions are distinct', () => {
|
||||
const writePermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.saveConfig,
|
||||
);
|
||||
const testPermissions = Reflect.getMetadata(
|
||||
'permissions',
|
||||
AiConfigController.prototype.testConnection,
|
||||
);
|
||||
expect(writePermissions).not.toEqual(testPermissions);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/server/src/ai-config/ai-config.controller.ts
Normal file
93
apps/server/src/ai-config/ai-config.controller.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user?: { id: number; username: string };
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
connection?: { remoteAddress?: string };
|
||||
}
|
||||
|
||||
@Controller('ai/config')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiConfigController {
|
||||
constructor(
|
||||
private readonly service: AiConfigService,
|
||||
private readonly opLog: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('ai:config:read')
|
||||
async getConfig() {
|
||||
const data = await this.service.getConfig();
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Put()
|
||||
@RequirePermission('ai:config:write')
|
||||
async saveConfig(@Body() body: SaveAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const config = await this.service.saveConfig(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'save',
|
||||
targetId: config.id,
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'} enabled=${body.enabled ?? config.enabled}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return { success: true, message: '配置已保存' };
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
@RequirePermission('ai:config:test')
|
||||
async testConnection(@Body() body: TestAiConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const result = await this.service.testConnection(body);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'test',
|
||||
targetType: 'AiConfig',
|
||||
detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
status: result.success ? 'success' : 'failure',
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('clear-key')
|
||||
@RequirePermission('ai:config:write')
|
||||
async clearKey(@Req() req: AuthenticatedRequest) {
|
||||
const data = await this.service.clearKey();
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.opLog.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: 'ai-config',
|
||||
action: 'clear-key',
|
||||
targetType: 'AiConfig',
|
||||
detail: `keySource=${data.keySource}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return { success: true, message: '密钥已清除', data };
|
||||
}
|
||||
}
|
||||
68
apps/server/src/ai-config/ai-config.entity.ts
Normal file
68
apps/server/src/ai-config/ai-config.entity.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum AiProvider {
|
||||
OPENAI = 'OPENAI',
|
||||
DEEPSEEK = 'DEEPSEEK',
|
||||
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
|
||||
}
|
||||
|
||||
export const SINGLETON_KEY = 'GLOBAL';
|
||||
|
||||
@Entity('ai_config')
|
||||
@Index('uq_ai_config_singleton', ['singletonKey'], { unique: true })
|
||||
export class AiConfig {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
|
||||
singletonKey: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
|
||||
provider: AiProvider;
|
||||
|
||||
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
|
||||
baseUrl: string;
|
||||
|
||||
@Column({ name: 'encrypted_api_key', type: 'text', nullable: true })
|
||||
encryptedApiKey: string | null;
|
||||
|
||||
@Column({ name: 'api_key_iv', type: 'varchar', length: 50, nullable: true })
|
||||
apiKeyIv: string | null;
|
||||
|
||||
@Column({ name: 'api_key_auth_tag', type: 'varchar', length: 50, nullable: true })
|
||||
apiKeyAuthTag: string | null;
|
||||
|
||||
@Column({ name: 'key_last4', type: 'varchar', length: 4, nullable: true })
|
||||
keyLast4: string | null;
|
||||
|
||||
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
|
||||
defaultModel: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
|
||||
timeoutMs: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
verified: boolean;
|
||||
|
||||
@Column({ name: 'last_tested_at', type: 'datetime', nullable: true })
|
||||
lastTestedAt: Date | null;
|
||||
|
||||
@Column({ name: 'last_test_latency_ms', type: 'int', nullable: true })
|
||||
lastTestLatencyMs: number | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
14
apps/server/src/ai-config/ai-config.module.ts
Normal file
14
apps/server/src/ai-config/ai-config.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AiConfig } from './ai-config.entity';
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { AiConfigController } from './ai-config.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AiConfig]), OperationLogsModule],
|
||||
controllers: [AiConfigController],
|
||||
providers: [AiConfigService],
|
||||
exports: [AiConfigService],
|
||||
})
|
||||
export class AiConfigModule {}
|
||||
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal file
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal file
@@ -0,0 +1,781 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
} from 'node:crypto';
|
||||
|
||||
import { AiConfigService } from './ai-config.service';
|
||||
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers for testing encryption directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function encryptWithKey(key: Buffer, plaintext: string) {
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
authTag: tag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decryptWithKey(
|
||||
key: Buffer,
|
||||
ciphertextB64: string,
|
||||
ivB64: string,
|
||||
authTagB64: string,
|
||||
): string {
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('AiConfigService', () => {
|
||||
let service: AiConfigService;
|
||||
let repo: jest.Mocked<Pick<Repository<AiConfig>, 'findOne' | 'save' | 'create'>>;
|
||||
|
||||
// Use a known encryption key so tests are deterministic
|
||||
const TEST_KEY_BYTES_32 = Buffer.alloc(32, 'a'); // 32 bytes of 'a'
|
||||
const TEST_KEY_HEX = TEST_KEY_BYTES_32.toString('hex'); // 64 hex chars
|
||||
|
||||
function makeConfig(overrides: Partial<AiConfig> = {}): AiConfig {
|
||||
return {
|
||||
id: 1,
|
||||
singletonKey: SINGLETON_KEY,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
encryptedApiKey: null,
|
||||
apiKeyIv: null,
|
||||
apiKeyAuthTag: null,
|
||||
keyLast4: null,
|
||||
defaultModel: null,
|
||||
enabled: false,
|
||||
timeoutMs: 30000,
|
||||
verified: false,
|
||||
lastTestedAt: null,
|
||||
lastTestLatencyMs: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
delete process.env.AI_API_KEY;
|
||||
|
||||
repo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AiConfigService>(AiConfigService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
delete process.env.AI_API_KEY;
|
||||
});
|
||||
|
||||
// ── Encryption ────────────────────────────────────────────────────────
|
||||
|
||||
describe('encryption', () => {
|
||||
it('roundtrip: encrypt then decrypt returns original text', () => {
|
||||
const plaintext = 'sk-test-key-1234567890abcdef';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(TEST_KEY_BYTES_32, plaintext);
|
||||
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, ciphertext, iv, authTag);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('random IV: same key + same plaintext produces different ciphertexts', () => {
|
||||
const plaintext = 'sk-test-key-1234567890abcdef';
|
||||
const key = TEST_KEY_BYTES_32;
|
||||
const r1 = encryptWithKey(key, plaintext);
|
||||
const r2 = encryptWithKey(key, plaintext);
|
||||
expect(r1.iv).not.toBe(r2.iv);
|
||||
expect(r1.ciphertext).not.toBe(r2.ciphertext);
|
||||
expect(decryptWithKey(key, r1.ciphertext, r1.iv, r1.authTag)).toBe(plaintext);
|
||||
expect(decryptWithKey(key, r2.ciphertext, r2.iv, r2.authTag)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('wrong key fails decryption', () => {
|
||||
const plaintext = 'sk-test-key-1234567890abcdef';
|
||||
const correctKey = TEST_KEY_BYTES_32;
|
||||
const wrongKey = Buffer.alloc(32, 'b');
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(correctKey, plaintext);
|
||||
expect(() =>
|
||||
decryptWithKey(wrongKey, ciphertext, iv, authTag),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('tampered auth tag fails decryption', () => {
|
||||
const plaintext = 'sk-test-key-1234567890abcdef';
|
||||
const key = TEST_KEY_BYTES_32;
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(key, plaintext);
|
||||
const tamperedTag = Buffer.from(authTag, 'base64');
|
||||
tamperedTag[0] ^= 1;
|
||||
expect(() =>
|
||||
decryptWithKey(key, ciphertext, iv, tamperedTag.toString('base64')),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Encryption key validation ─────────────────────────────────────────
|
||||
|
||||
describe('encryption key validation', () => {
|
||||
it('rejects plain 32-char string (not hex or base64)', async () => {
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
|
||||
// Re-create service with new key
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
const svc = module.get<AiConfigService>(AiConfigService);
|
||||
|
||||
await expect(
|
||||
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
|
||||
it('rejects invalid base64 input', async () => {
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!invalid!!!';
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
const svc = module.get<AiConfigService>(AiConfigService);
|
||||
|
||||
await expect(
|
||||
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
|
||||
it('accepts valid base64 32-byte key', async () => {
|
||||
const b64key = TEST_KEY_BYTES_32.toString('base64');
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = b64key;
|
||||
const existing = makeConfig();
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
const svc = module.get<AiConfigService>(AiConfigService);
|
||||
|
||||
const result = await svc.saveConfig({ provider: AiProvider.OPENAI });
|
||||
expect(result).toBeDefined();
|
||||
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
|
||||
it('rejects non-canonical base64 (extra padding)', async () => {
|
||||
const canonicalB64 = TEST_KEY_BYTES_32.toString('base64');
|
||||
// Non-canonical: add extra padding
|
||||
const badB64 = canonicalB64 + '==';
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = badB64;
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
const svc = module.get<AiConfigService>(AiConfigService);
|
||||
|
||||
await expect(
|
||||
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
|
||||
it('rejects base64 with invalid characters', async () => {
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!!aaaa';
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AiConfigService,
|
||||
{ provide: getRepositoryToken(AiConfig), useValue: repo },
|
||||
],
|
||||
}).compile();
|
||||
const svc = module.get<AiConfigService>(AiConfigService);
|
||||
|
||||
await expect(
|
||||
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
|
||||
it('throws in production when no key set', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
|
||||
await expect(
|
||||
service.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
delete process.env.NODE_ENV;
|
||||
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Config management ─────────────────────────────────────────────────
|
||||
|
||||
describe('getOrCreateConfig', () => {
|
||||
it('returns existing config when found', async () => {
|
||||
const existing = makeConfig();
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
const result = await service.getOrCreateConfig();
|
||||
expect(result).toBe(existing);
|
||||
expect(repo.findOne).toHaveBeenCalledWith({ where: { singletonKey: SINGLETON_KEY } });
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates default config when none exists', async () => {
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
const created = makeConfig();
|
||||
repo.create.mockReturnValue(created);
|
||||
repo.save.mockResolvedValue(created);
|
||||
const result = await service.getOrCreateConfig();
|
||||
expect(repo.create).toHaveBeenCalled();
|
||||
expect(result.provider).toBe(AiProvider.OPENAI);
|
||||
expect(result.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('returns masked key info with source=none when no key', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
const result = await service.getConfig();
|
||||
expect(result.hasApiKey).toBe(false);
|
||||
expect(result.hasDatabaseKey).toBe(false);
|
||||
expect(result.maskedApiKey).toBeNull();
|
||||
expect(result.keySource).toBe('none');
|
||||
});
|
||||
|
||||
it('returns hasApiKey=true and hasDatabaseKey=true when DB key exists', async () => {
|
||||
const key = 'sk-abcdefghij1234';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
key,
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: '1234',
|
||||
}),
|
||||
);
|
||||
const result = await service.getConfig();
|
||||
expect(result.hasApiKey).toBe(true);
|
||||
expect(result.hasDatabaseKey).toBe(true);
|
||||
expect(result.maskedApiKey).toBe('••••1234');
|
||||
expect(result.keySource).toBe('database');
|
||||
});
|
||||
|
||||
it('never returns plaintext key in GET response', async () => {
|
||||
const key = 'sk-topsecret1234';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
key,
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: '1234',
|
||||
}),
|
||||
);
|
||||
const result = await service.getConfig();
|
||||
const json = JSON.stringify(result);
|
||||
expect(json).not.toContain('topsecret');
|
||||
expect(json).not.toContain('sk-');
|
||||
});
|
||||
|
||||
it('env key fallback: source=environment, hasDatabaseKey=false', async () => {
|
||||
process.env.AI_API_KEY = 'sk-env-key-1234';
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
const result = await service.getConfig();
|
||||
expect(result.hasApiKey).toBe(true);
|
||||
expect(result.hasDatabaseKey).toBe(false);
|
||||
expect(result.keySource).toBe('environment');
|
||||
delete process.env.AI_API_KEY;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Save config ───────────────────────────────────────────────────────
|
||||
|
||||
describe('saveConfig', () => {
|
||||
it('saves provider and baseUrl', async () => {
|
||||
const existing = makeConfig();
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.DEEPSEEK,
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
});
|
||||
|
||||
expect(result.provider).toBe(AiProvider.DEEPSEEK);
|
||||
expect(result.baseUrl).toBe('https://api.deepseek.com');
|
||||
});
|
||||
|
||||
it('encrypts and saves apiKey', async () => {
|
||||
const existing = makeConfig();
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const apiKey = 'sk-saved-key-5678';
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
apiKey,
|
||||
});
|
||||
|
||||
expect(result.encryptedApiKey).toBeTruthy();
|
||||
expect(result.apiKeyIv).toBeTruthy();
|
||||
expect(result.apiKeyAuthTag).toBeTruthy();
|
||||
expect(result.keyLast4).toBe('5678');
|
||||
|
||||
const encKey = result.encryptedApiKey;
|
||||
const encIv = result.apiKeyIv;
|
||||
const encTag = result.apiKeyAuthTag;
|
||||
expect(encKey).toBeTruthy();
|
||||
expect(encIv).toBeTruthy();
|
||||
expect(encTag).toBeTruthy();
|
||||
if (!encKey || !encIv || !encTag) throw new Error('encrypted fields missing');
|
||||
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, encKey, encIv, encTag);
|
||||
expect(decrypted).toBe(apiKey);
|
||||
});
|
||||
|
||||
it('empty apiKey preserves existing key', async () => {
|
||||
const existingKey = 'sk-existing-9999';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
existingKey,
|
||||
);
|
||||
const existing = makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: '9999',
|
||||
});
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
apiKey: '',
|
||||
});
|
||||
|
||||
expect(result.encryptedApiKey).toBe(ciphertext);
|
||||
expect(result.keyLast4).toBe('9999');
|
||||
});
|
||||
|
||||
it('undefined apiKey preserves existing key', async () => {
|
||||
const existingKey = 'sk-existing-9999';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
existingKey,
|
||||
);
|
||||
const existing = makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: '9999',
|
||||
});
|
||||
repo.findOne.mockResolvedValue(existing);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
});
|
||||
|
||||
expect(result.encryptedApiKey).toBe(ciphertext);
|
||||
});
|
||||
|
||||
it('rejects enabled=true without any key', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
enabled: true,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows enabled=true when DB key exists and defaultModel is set', async () => {
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-existing-key',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag, defaultModel: 'gpt-4' }),
|
||||
);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('allows enabled=true with env key fallback and defaultModel', async () => {
|
||||
process.env.AI_API_KEY = 'sk-env-key';
|
||||
repo.findOne.mockResolvedValue(makeConfig({ defaultModel: 'gpt-4' }));
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
enabled: true,
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
delete process.env.AI_API_KEY;
|
||||
});
|
||||
|
||||
it('provider switch replaces default baseUrl', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig({ baseUrl: 'https://api.openai.com/v1' }));
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.DEEPSEEK,
|
||||
});
|
||||
expect(result.baseUrl).toBe('https://api.deepseek.com');
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE requires baseUrl', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('enabled=true with defaultModel in DTO works even if config has none', async () => {
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-existing-key',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
|
||||
);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
enabled: true,
|
||||
defaultModel: 'gpt-4',
|
||||
});
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enabled=true rejects when defaultModel is empty everywhere', async () => {
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-existing-key',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
|
||||
);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
enabled: true,
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Clear key ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('clearKey', () => {
|
||||
it('clears DB key and disables when no env key', async () => {
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-to-clear',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: 'lear',
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.clearKey();
|
||||
expect(result.hasApiKey).toBe(false);
|
||||
expect(result.hasDatabaseKey).toBe(false);
|
||||
expect(result.keySource).toBe('none');
|
||||
expect(result.maskedApiKey).toBeNull();
|
||||
});
|
||||
|
||||
it('clear DB key falls back to env source', async () => {
|
||||
process.env.AI_API_KEY = 'sk-env-after-clear';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-to-clear',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
keyLast4: 'lear',
|
||||
}),
|
||||
);
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.clearKey();
|
||||
expect(result.keySource).toBe('environment');
|
||||
expect(result.hasDatabaseKey).toBe(false);
|
||||
delete process.env.AI_API_KEY;
|
||||
});
|
||||
});
|
||||
|
||||
// ── URL / SSRF validation ──────────────────────────────────────────────
|
||||
|
||||
describe('baseUrl validation', () => {
|
||||
it('rejects non-http protocol', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'ftp://evil.com',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects URL with username/password', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'https://user:pass@evil.com',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects URL with search/query string', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'https://evil.com/v1?proxy=internal',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects URL with hash/fragment', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'https://evil.com/v1#section',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects localhost for OPENAI_COMPATIBLE', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'http://localhost:8080/v1',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects 127.0.0.1 for OPENAI_COMPATIBLE', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'https://127.0.0.1:8080',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects .local hostname', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'https://myservice.local/v1',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects IPv6 loopback ::1', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'http://[::1]:8080/v1',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows private IP when AI_ALLOW_PRIVATE_BASE_URL=true', async () => {
|
||||
process.env.AI_ALLOW_PRIVATE_BASE_URL = 'true';
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI_COMPATIBLE,
|
||||
baseUrl: 'http://localhost:8080/v1',
|
||||
});
|
||||
expect(result.baseUrl).toBe('http://localhost:8080/v1');
|
||||
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
|
||||
});
|
||||
|
||||
it('OPENAI rejects non-openai hostname', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://evil.com/v1',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('OPENAI rejects wrong pathname', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
await expect(
|
||||
service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://api.openai.com/evil-proxy',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('normalizes trailing slash', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig());
|
||||
repo.save.mockImplementation((c) => Promise.resolve(c));
|
||||
|
||||
const result = await service.saveConfig({
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: 'https://api.openai.com/v1/',
|
||||
});
|
||||
expect(result.baseUrl).toBe('https://api.openai.com/v1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getRuntimeConfig ──────────────────────────────────────────────────
|
||||
|
||||
describe('getRuntimeConfig', () => {
|
||||
it('returns config with plaintext key', async () => {
|
||||
const apiKey = 'sk-runtime-key';
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
apiKey,
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
enabled: true,
|
||||
defaultModel: 'gpt-4',
|
||||
}),
|
||||
);
|
||||
|
||||
const runtime = await service.getRuntimeConfig();
|
||||
expect(runtime.apiKey).toBe(apiKey);
|
||||
expect(runtime.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when not enabled', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig({ enabled: false }));
|
||||
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws when no key available', async () => {
|
||||
repo.findOne.mockResolvedValue(makeConfig({ enabled: true }));
|
||||
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws when defaultModel is empty', async () => {
|
||||
const { ciphertext, iv, authTag } = encryptWithKey(
|
||||
TEST_KEY_BYTES_32,
|
||||
'sk-runtime-key',
|
||||
);
|
||||
repo.findOne.mockResolvedValue(
|
||||
makeConfig({
|
||||
encryptedApiKey: ciphertext,
|
||||
apiKeyIv: iv,
|
||||
apiKeyAuthTag: authTag,
|
||||
enabled: true,
|
||||
defaultModel: null,
|
||||
}),
|
||||
);
|
||||
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('throws when no config row exists', async () => {
|
||||
repo.findOne.mockResolvedValue(null);
|
||||
await expect(service.getRuntimeConfig()).rejects.toThrow(InternalServerErrorException);
|
||||
});
|
||||
});
|
||||
});
|
||||
753
apps/server/src/ai-config/ai-config.service.ts
Normal file
753
apps/server/src/ai-config/ai-config.service.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { isIP } from 'node:net';
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
|
||||
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
|
||||
import {
|
||||
SaveAiConfigDto,
|
||||
TestAiConfigDto,
|
||||
AiConfigResponseDto,
|
||||
AiConfigTestResultDto,
|
||||
AiRuntimeConfig,
|
||||
DEFAULT_BASE_URLS,
|
||||
} from './dto/ai-config.dto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key derivation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _encryptionWarned = false;
|
||||
|
||||
function getEncryptionKey(): Buffer {
|
||||
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
|
||||
if (!raw) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
if (!_encryptionWarned) {
|
||||
_encryptionWarned = true;
|
||||
Logger.warn(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
|
||||
'AiConfigService',
|
||||
);
|
||||
}
|
||||
// 32 hex pairs → 32 bytes
|
||||
return Buffer.from('ff'.repeat(32), 'hex');
|
||||
}
|
||||
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
|
||||
}
|
||||
|
||||
// Hex: exactly 64 hex chars
|
||||
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
|
||||
return Buffer.from(raw, 'hex');
|
||||
}
|
||||
|
||||
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
|
||||
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
|
||||
const buf = Buffer.from(raw, 'base64');
|
||||
if (buf.length !== 32) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 解码后须为 32 字节',
|
||||
);
|
||||
}
|
||||
// Re-encode to canonical base64 (no line breaks) and compare
|
||||
const canonical = buf.toString('base64');
|
||||
if (raw !== canonical) {
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:base64 编码须为标准格式(无多余 padding)',
|
||||
);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(
|
||||
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
|
||||
);
|
||||
}
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
|
||||
const key = getEncryptionKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: encrypted.toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
authTag: tag.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
|
||||
const key = getEncryptionKey();
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const authTag = Buffer.from(authTagB64, 'base64');
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextB64, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
return decrypted.toString('utf-8');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// URL / SSRF helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PRIVATE_IPV4_RANGES = [
|
||||
/^127\./,
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2\d|3[01])\./,
|
||||
/^192\.168\./,
|
||||
/^169\.254\./,
|
||||
/^0\./,
|
||||
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
|
||||
];
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Strip IPv6 brackets from URL.hostname
|
||||
if (hostname.startsWith('[') && hostname.endsWith(']')) {
|
||||
hostname = hostname.slice(1, -1);
|
||||
}
|
||||
|
||||
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
|
||||
if (hostname.endsWith('.local')) return true;
|
||||
|
||||
if (isIP(hostname) === 6) {
|
||||
// IPv6 private/loopback
|
||||
if (hostname === '::1' || hostname === '::') return true;
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
|
||||
if (
|
||||
lower.startsWith('fe8') ||
|
||||
lower.startsWith('fe9') ||
|
||||
lower.startsWith('fea') ||
|
||||
lower.startsWith('feb')
|
||||
)
|
||||
return true; // fe80::/10
|
||||
// IPv4-mapped IPv6: ::ffff:0:0/96
|
||||
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIP(hostname) === 4) {
|
||||
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Known provider hosts — only these are allowed for fixed providers
|
||||
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
|
||||
[AiProvider.OPENAI]: ['api.openai.com'],
|
||||
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
|
||||
};
|
||||
|
||||
// Required pathname for fixed providers
|
||||
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
|
||||
[AiProvider.OPENAI]: '/v1',
|
||||
[AiProvider.DEEPSEEK]: '/',
|
||||
};
|
||||
|
||||
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
|
||||
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
|
||||
if (!raw) {
|
||||
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
|
||||
}
|
||||
|
||||
// Reject search/query and hash/fragment
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
|
||||
throw new BadRequestException('生产环境禁止使用 http://');
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
throw new BadRequestException('请求参数无效');
|
||||
}
|
||||
|
||||
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
||||
|
||||
// Provider-specific host check
|
||||
const allowedHosts = PROVIDER_HOSTS[provider];
|
||||
if (allowedHosts) {
|
||||
if (!allowedHosts.includes(parsed.hostname)) {
|
||||
throw new BadRequestException(`${provider} 必须使用固定域名`);
|
||||
}
|
||||
// Enforce exact path for fixed providers
|
||||
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
|
||||
if (
|
||||
requiredPath !== undefined &&
|
||||
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
|
||||
) {
|
||||
throw new BadRequestException(`请求参数无效`);
|
||||
}
|
||||
} else {
|
||||
// OPENAI_COMPATIBLE — SSRF check
|
||||
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
|
||||
throw new BadRequestException('不允许使用内网地址');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function resolveHostnames(hostname: string): Promise<{ address: string; family: number }[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
if (!addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析返回空结果'));
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
addresses.map((a) => ({
|
||||
address: a.address,
|
||||
family: a.family,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDnsNotPrivate(hostname: string): Promise<void> {
|
||||
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
|
||||
if (allowPrivate) return;
|
||||
|
||||
let addresses: { address: string; family: number }[];
|
||||
try {
|
||||
addresses = await resolveHostnames(hostname);
|
||||
} catch {
|
||||
throw new BadRequestException('无法解析域名');
|
||||
}
|
||||
|
||||
for (const { address } of addresses) {
|
||||
if (isPrivateHost(address)) {
|
||||
throw new BadRequestException('域名解析到内网地址');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection test — uses node:http/https with DNS pinning to prevent rebinding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
|
||||
|
||||
/**
|
||||
* Perform a pinned HTTP GET request.
|
||||
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
|
||||
* Redirects are forbidden. HTTPS certificate validation is enforced.
|
||||
*/
|
||||
function pinnedGet(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
timeoutMs: number,
|
||||
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const isHttps = parsed.protocol === 'https:';
|
||||
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
|
||||
const hostname = parsed.hostname;
|
||||
const path = parsed.pathname + parsed.search;
|
||||
|
||||
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
|
||||
if (dnsErr || !addresses || addresses.length === 0) {
|
||||
reject(new Error('DNS 解析失败'));
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = addresses.find((a) => !isPrivateHost(a.address));
|
||||
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
|
||||
reject(new Error('解析到内网地址'));
|
||||
return;
|
||||
}
|
||||
const targetIp = resolved ? resolved.address : addresses[0].address;
|
||||
const family = resolved ? resolved.family : addresses[0].family;
|
||||
|
||||
const transport = isHttps ? https : http;
|
||||
|
||||
const requestStart = Date.now();
|
||||
|
||||
const req = transport.request(
|
||||
{
|
||||
hostname: targetIp,
|
||||
port,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { ...headers, Host: hostname },
|
||||
servername: isHttps ? hostname : undefined,
|
||||
rejectUnauthorized: isHttps,
|
||||
family: family === 6 ? 6 : 4,
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
const latencyMs = Date.now() - requestStart;
|
||||
const status = res.statusCode ?? 500;
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume();
|
||||
res.destroy();
|
||||
return reject(new Error('禁止重定向'));
|
||||
}
|
||||
|
||||
const contentType = res.headers['content-type'] ?? null;
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_RESPONSE_BYTES) {
|
||||
res.destroy();
|
||||
reject(new Error('响应过大'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
resolve({ status, contentType, body, latencyMs });
|
||||
});
|
||||
|
||||
res.on('error', reject);
|
||||
},
|
||||
);
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('连接超时'));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Injectable()
|
||||
export class AiConfigService {
|
||||
private readonly logger = new Logger(AiConfigService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AiConfig)
|
||||
private readonly repo: Repository<AiConfig>,
|
||||
) {}
|
||||
|
||||
/** Resolve the effective API key: DB first, then env, then none */
|
||||
private resolveApiKey(config: AiConfig | null): {
|
||||
plaintext: string | null;
|
||||
source: 'database' | 'environment' | 'none';
|
||||
} {
|
||||
// DB stored key
|
||||
if (config?.encryptedApiKey && config?.apiKeyIv && config?.apiKeyAuthTag) {
|
||||
try {
|
||||
const plaintext = decrypt(config.encryptedApiKey, config.apiKeyIv, config.apiKeyAuthTag);
|
||||
return { plaintext, source: 'database' };
|
||||
} catch {
|
||||
this.logger.error('解密数据库 API Key 失败,密文可能已损坏');
|
||||
throw new InternalServerErrorException('无法解密 API Key');
|
||||
}
|
||||
}
|
||||
|
||||
// Environment fallback
|
||||
const envKey = process.env.AI_API_KEY;
|
||||
if (envKey) {
|
||||
return { plaintext: envKey, source: 'environment' };
|
||||
}
|
||||
|
||||
return { plaintext: null, source: 'none' };
|
||||
}
|
||||
|
||||
/** Load or create the singleton config row */
|
||||
async getOrCreateConfig(): Promise<AiConfig> {
|
||||
let config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (!config) {
|
||||
config = this.repo.create({
|
||||
singletonKey: SINGLETON_KEY,
|
||||
provider: AiProvider.OPENAI,
|
||||
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
|
||||
enabled: false,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
try {
|
||||
config = await this.repo.save(config);
|
||||
} catch (err: unknown) {
|
||||
// Unique constraint violation → another request created it first
|
||||
const isErrWithCode = err !== null && typeof err === 'object' && 'code' in err;
|
||||
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
|
||||
const errno = isErrWithCode ? (err as Record<string, unknown>).errno : undefined;
|
||||
// MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062
|
||||
// SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT')
|
||||
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
|
||||
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (existing) return existing;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Build masked key display */
|
||||
private buildMaskedKey(keyLast4: string | null): string | null {
|
||||
if (keyLast4 && keyLast4.length === 4) {
|
||||
return `••••${keyLast4}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** GET response */
|
||||
async getConfig(): Promise<AiConfigResponseDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
const { source } = this.resolveApiKey(config);
|
||||
|
||||
const hasDbKey = !!(config.encryptedApiKey && config.apiKeyIv && config.apiKeyAuthTag);
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
provider: config.provider,
|
||||
baseUrl: config.baseUrl,
|
||||
hasApiKey: source !== 'none',
|
||||
hasDatabaseKey: hasDbKey,
|
||||
maskedApiKey: config.keyLast4
|
||||
? this.buildMaskedKey(config.keyLast4)
|
||||
: source !== 'none'
|
||||
? '••••'
|
||||
: null,
|
||||
keySource: source,
|
||||
defaultModel: config.defaultModel ?? null,
|
||||
enabled: config.enabled,
|
||||
timeoutMs: config.timeoutMs,
|
||||
verified: config.verified,
|
||||
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
|
||||
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
|
||||
createdAt: config.createdAt.toISOString(),
|
||||
updatedAt: config.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** PUT / save */
|
||||
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
|
||||
// Validate and normalize baseUrl
|
||||
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
|
||||
|
||||
// DNS SSRF check for all providers
|
||||
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
||||
|
||||
config.provider = dto.provider;
|
||||
config.baseUrl = normalizedBaseUrl;
|
||||
|
||||
if (dto.defaultModel !== undefined) {
|
||||
config.defaultModel = dto.defaultModel || null;
|
||||
}
|
||||
|
||||
if (dto.timeoutMs !== undefined) {
|
||||
config.timeoutMs = dto.timeoutMs;
|
||||
}
|
||||
|
||||
// Handle apiKey — empty/undefined = keep existing
|
||||
if (dto.apiKey !== undefined && dto.apiKey !== '') {
|
||||
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
|
||||
config.encryptedApiKey = ciphertext;
|
||||
config.apiKeyIv = iv;
|
||||
config.apiKeyAuthTag = authTag;
|
||||
config.keyLast4 = dto.apiKey.slice(-4);
|
||||
}
|
||||
|
||||
// enabled validation
|
||||
if (dto.enabled !== undefined) {
|
||||
if (dto.enabled) {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
throw new BadRequestException('未配置 API Key,无法启用。请先保存 API Key 再启用');
|
||||
}
|
||||
// defaultModel is required when enabled
|
||||
const effectiveDefaultModel =
|
||||
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
|
||||
if (!effectiveDefaultModel) {
|
||||
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
|
||||
}
|
||||
}
|
||||
config.enabled = dto.enabled;
|
||||
}
|
||||
|
||||
return this.repo.save(config);
|
||||
}
|
||||
|
||||
/** Clear DB key only */
|
||||
async clearKey(): Promise<AiConfigResponseDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
config.encryptedApiKey = null;
|
||||
config.apiKeyIv = null;
|
||||
config.apiKeyAuthTag = null;
|
||||
config.keyLast4 = null;
|
||||
// If no env key either, disable
|
||||
const envKey = process.env.AI_API_KEY;
|
||||
if (!envKey) {
|
||||
config.enabled = false;
|
||||
}
|
||||
await this.repo.save(config);
|
||||
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
/** Test connection — uses saved config or request body overrides */
|
||||
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
|
||||
const config = await this.getOrCreateConfig();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Determine effective provider / baseUrl
|
||||
const provider = dto?.provider ?? config.provider;
|
||||
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
|
||||
let baseUrl: string;
|
||||
try {
|
||||
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine effective defaultModel
|
||||
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
|
||||
|
||||
// DNS check
|
||||
try {
|
||||
await validateDnsNotPrivate(new URL(baseUrl).hostname);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine API key
|
||||
let apiKey: string;
|
||||
if (dto?.apiKey) {
|
||||
apiKey = dto.apiKey;
|
||||
} else {
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
apiKey = plaintext;
|
||||
}
|
||||
|
||||
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
|
||||
|
||||
let result: AiConfigTestResultDto;
|
||||
try {
|
||||
const { status, contentType, body, latencyMs } = await pinnedGet(
|
||||
`${baseUrl}/models`,
|
||||
{ Authorization: `Bearer ${apiKey}` },
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
// Classify by HTTP status first, then content-type
|
||||
if (status === 401 || status === 403) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '认证失败,请检查 API Key',
|
||||
};
|
||||
} else if (status >= 500) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '服务不可用',
|
||||
};
|
||||
} else if (status >= 400) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: `服务返回错误状态 ${status}`,
|
||||
};
|
||||
} else if (!contentType || !contentType.includes('application/json')) {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
} else {
|
||||
let data: { data?: Array<{ id: string }> };
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(body);
|
||||
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
|
||||
data = parsed;
|
||||
} catch {
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message: '响应格式无效',
|
||||
};
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = latencyMs;
|
||||
config.verified = false;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
const models = Array.isArray(data?.data) ? data.data : [];
|
||||
const modelCount = models.length;
|
||||
const modelAvailable =
|
||||
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
|
||||
|
||||
const message = modelAvailable
|
||||
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
|
||||
: effectiveDefaultModel
|
||||
? '连接成功,但未找到目标模型'
|
||||
: models.length > 0
|
||||
? `连接成功,可用模型 ${models.length} 个`
|
||||
: '连接成功,但未返回可用模型';
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
latencyMs,
|
||||
modelCount,
|
||||
modelAvailable,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.message === '连接超时'
|
||||
? '连接超时'
|
||||
: err.message === '响应过大'
|
||||
? '响应过大'
|
||||
: err.message === '禁止重定向'
|
||||
? '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL'
|
||||
: '连接失败,请检查 Base URL';
|
||||
result = {
|
||||
success: false,
|
||||
latencyMs: null,
|
||||
modelCount: null,
|
||||
modelAvailable: false,
|
||||
testedAt: now,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
// Update last tested info on config
|
||||
config.lastTestedAt = new Date();
|
||||
config.lastTestLatencyMs = result.latencyMs;
|
||||
config.verified = result.success;
|
||||
await this.repo.save(config);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-only runtime config — for future AI adapters.
|
||||
* Re-validates the stored base URL and DNS at runtime to guard
|
||||
* against config-table tampering or DNS record changes.
|
||||
* Future adapters should still use a restricted transport helper.
|
||||
*/
|
||||
async getRuntimeConfig(): Promise<AiRuntimeConfig> {
|
||||
const config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
|
||||
if (!config) {
|
||||
throw new InternalServerErrorException('AI 配置未初始化');
|
||||
}
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new BadRequestException('AI 服务未启用');
|
||||
}
|
||||
|
||||
// Re-validate and normalize the stored base URL
|
||||
const normalizedBaseUrl = validateAndNormalizeBaseUrl(config.baseUrl, config.provider);
|
||||
|
||||
// Re-check DNS at runtime
|
||||
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
|
||||
|
||||
const { plaintext } = this.resolveApiKey(config);
|
||||
if (!plaintext) {
|
||||
throw new BadRequestException('未配置 API Key');
|
||||
}
|
||||
|
||||
// defaultModel is required for actual AI calls
|
||||
if (!config.defaultModel) {
|
||||
throw new BadRequestException('未配置默认模型');
|
||||
}
|
||||
|
||||
return {
|
||||
provider: config.provider,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
apiKey: plaintext,
|
||||
defaultModel: config.defaultModel,
|
||||
timeoutMs: config.timeoutMs,
|
||||
enabled: config.enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal file
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { validate } from 'class-validator';
|
||||
import { SaveAiConfigDto, TestAiConfigDto } from './ai-config.dto';
|
||||
import { AiProvider } from '../ai-config.entity';
|
||||
|
||||
describe('SaveAiConfigDto', () => {
|
||||
it('validates a correct OPENAI config', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('validates a correct DEEPSEEK config', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.DEEPSEEK;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fixed provider can omit baseUrl', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('fixed provider can provide baseUrl (valid string)', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.baseUrl = 'https://api.openai.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE must provide baseUrl', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE with baseUrl passes', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = 'https://custom.api.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('OPENAI_COMPATIBLE with empty baseUrl fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = '';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
|
||||
it('invalid provider fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
(dto as Record<string, unknown>).provider = 'INVALID';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
|
||||
it('timeoutMs outside range fails', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.timeoutMs = 500;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('apiKey is optional string', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.apiKey = 'sk-test-1234';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('defaultModel is optional string', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.defaultModel = 'gpt-4';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('enabled is optional boolean', async () => {
|
||||
const dto = new SaveAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI;
|
||||
dto.enabled = true;
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestAiConfigDto', () => {
|
||||
it('empty DTO is valid (all fields optional)', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('partial fields validate', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
dto.provider = AiProvider.OPENAI_COMPATIBLE;
|
||||
dto.baseUrl = 'https://custom.api.com/v1';
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('invalid provider fails', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
(dto as Record<string, unknown>).provider = 'INVALID';
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('timeoutMs outside range fails', async () => {
|
||||
const dto = new TestAiConfigDto();
|
||||
dto.timeoutMs = 0;
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal file
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
IsString,
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { AiProvider } from '../ai-config.entity';
|
||||
|
||||
const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COMPATIBLE] as const;
|
||||
|
||||
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
|
||||
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
|
||||
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
|
||||
[AiProvider.OPENAI_COMPATIBLE]: '',
|
||||
};
|
||||
|
||||
/** DTO for PUT /api/ai/config — all fields required or validated */
|
||||
export class SaveAiConfigDto {
|
||||
@IsIn(PROVIDERS)
|
||||
provider!: AiProvider;
|
||||
|
||||
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
|
||||
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
/** Raw API key — never returned by GET; empty / undefined = keep existing */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultModel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** DTO for POST /api/ai/config/test — all fields optional, validate only when provided */
|
||||
export class TestAiConfigDto {
|
||||
@IsOptional()
|
||||
@IsIn(PROVIDERS)
|
||||
provider?: AiProvider;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
apiKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
defaultModel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
@Max(120000)
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Response shape for GET /api/ai/config — NEVER includes plaintext key */
|
||||
export interface AiConfigResponseDto {
|
||||
id: number;
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
hasApiKey: boolean;
|
||||
hasDatabaseKey: boolean;
|
||||
maskedApiKey: string | null;
|
||||
keySource: 'database' | 'environment' | 'none';
|
||||
defaultModel: string | null;
|
||||
enabled: boolean;
|
||||
timeoutMs: number;
|
||||
verified: boolean;
|
||||
lastTestedAt: string | null;
|
||||
lastTestLatencyMs: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Response shape for POST /api/ai/config/test */
|
||||
export interface AiConfigTestResultDto {
|
||||
success: boolean;
|
||||
latencyMs: number | null;
|
||||
modelCount: number | null;
|
||||
modelAvailable: boolean;
|
||||
testedAt: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Server-only runtime config — NEVER exported via controller DTO */
|
||||
export interface AiRuntimeConfig {
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
timeoutMs: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export { DEFAULT_BASE_URLS };
|
||||
@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import {
|
||||
Student,
|
||||
@@ -40,11 +40,14 @@ import {
|
||||
ResultArchive,
|
||||
ArchiveAttachment,
|
||||
StudentDingMapping,
|
||||
AiConfig,
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { AuthorizationModule } from './authorization';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
import { StudentsModule } from './students/students.module';
|
||||
import { PermissionGuard } from './auth/guards/permission.guard';
|
||||
import { PoliciesGuard } from './authorization/guards/policies.guard';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { RoomsModule } from './rooms/rooms.module';
|
||||
import { OccupanciesModule } from './occupancies/occupancies.module';
|
||||
@@ -64,6 +67,8 @@ import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ArchiveModule } from './archive/archive.module';
|
||||
import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||
import { AgentToolsModule } from './agent-tools';
|
||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||
|
||||
import {
|
||||
IntegrationConfig,
|
||||
@@ -73,6 +78,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthorizationModule,
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
@@ -84,7 +90,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): any => {
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions => {
|
||||
const dbType = config.get('DB_TYPE', 'sqlite');
|
||||
const allEntities = [
|
||||
Student,
|
||||
@@ -124,6 +130,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
StudentDingMapping,
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
AiConfig,
|
||||
];
|
||||
if (dbType === 'mysql') {
|
||||
return {
|
||||
@@ -131,8 +138,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
host: config.get('DB_HOST', 'localhost'),
|
||||
port: config.get<number>('DB_PORT', 3306),
|
||||
username: config.get('DB_USERNAME', 'root'),
|
||||
password: config.get('DB_PASSWORD', ''),
|
||||
database: config.get('DB_DATABASE', 'dorm_billing'),
|
||||
password: config.get<string>('DB_PASSWORD', ''),
|
||||
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
|
||||
entities: allEntities,
|
||||
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||
charset: 'utf8mb4',
|
||||
@@ -140,7 +147,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
}
|
||||
return {
|
||||
type: 'better-sqlite3' as const,
|
||||
database: config.get('DB_DATABASE', 'dorm_billing.db'),
|
||||
database: config.get<string>('DB_DATABASE', 'dorm_billing.db'),
|
||||
entities: allEntities,
|
||||
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||
};
|
||||
@@ -167,12 +174,15 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
NotificationsModule,
|
||||
ArchiveModule,
|
||||
IntegrationConfigModule,
|
||||
AgentToolsModule,
|
||||
ExpenseTypesModule,
|
||||
AiConfigModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
{ provide: APP_GUARD, useClass: PoliciesGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -15,6 +15,9 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
const logService = {
|
||||
log: jest.fn(),
|
||||
};
|
||||
const authzService = {
|
||||
can: jest.fn().mockReturnValue(false),
|
||||
};
|
||||
|
||||
let controller: AttendanceController;
|
||||
|
||||
@@ -24,6 +27,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
attendanceService as unknown as AttendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService as unknown as OperationLogsService,
|
||||
authzService as never,
|
||||
);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
@@ -115,6 +119,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
|
||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
|
||||
|
||||
authzService.can.mockReturnValue(true);
|
||||
await expect(
|
||||
controller.getDingTalkImportClasses({
|
||||
user: {
|
||||
|
||||
@@ -35,6 +35,8 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
|
||||
/** SSE event shape for @Sse() decorator */
|
||||
interface SseEvent {
|
||||
@@ -47,8 +49,9 @@ interface SseEvent {
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,6 +61,7 @@ export class AttendanceController {
|
||||
private readonly service: AttendanceService,
|
||||
private readonly importService: AttendanceImportService,
|
||||
private readonly logService: OperationLogsService,
|
||||
private readonly authz: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private getTodayDateOnly(): string {
|
||||
@@ -68,16 +72,20 @@ export class AttendanceController {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private canManageAllAttendance(user: RequestUser): boolean {
|
||||
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
|
||||
private canManageAllAttendance(req: { user: RequestUser }): boolean {
|
||||
return (
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
|
||||
// Legacy: class:edit grants broad attendance access for teacher scoping
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
|
||||
private getAccessibleClassIds(req: { user: RequestUser }) {
|
||||
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
private assertClassAccess(user: RequestUser, classId: number) {
|
||||
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
|
||||
private assertClassAccess(req: { user: RequestUser }, classId: number) {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@@ -124,8 +132,8 @@ export class AttendanceController {
|
||||
@Res() res: Response,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const classIds = await this.getAccessibleClassIds(req.user);
|
||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||
const classIds = await this.getAccessibleClassIds(req);
|
||||
const records = await this.service.findAllForExport(query, classIds);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
@@ -176,8 +184,8 @@ export class AttendanceController {
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
|
||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||
return this.service.findAll(query, await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@@ -228,7 +236,7 @@ export class AttendanceController {
|
||||
@Get('attendance-records/classes')
|
||||
@RequirePermission('attendance:view')
|
||||
async getClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
|
||||
return this.service.getClasses(await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
@@ -238,8 +246,8 @@ export class AttendanceController {
|
||||
@Query() query: AttendanceSummaryQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
|
||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
// ── Attendance calendar ──
|
||||
@@ -249,7 +257,7 @@ export class AttendanceController {
|
||||
@Query() query: AttendanceCalendarQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req.user, query.classId);
|
||||
await this.assertClassAccess(req, query.classId);
|
||||
return this.service.getCalendar(query);
|
||||
}
|
||||
|
||||
@@ -257,8 +265,8 @@ export class AttendanceController {
|
||||
@Get('ding-attendance-raw')
|
||||
@RequirePermission('attendance:view')
|
||||
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
|
||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -293,11 +301,8 @@ export class AttendanceController {
|
||||
@Res() res: Response,
|
||||
@Request() req: any,
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const reportData = await this.service.getReport(
|
||||
query,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -363,7 +368,7 @@ export class AttendanceController {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
await this.getAccessibleClassIds(req),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +385,7 @@ export class AttendanceController {
|
||||
@Get('attendance-records/import/dingtalk/classes')
|
||||
@RequirePermission('attendance:create')
|
||||
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
|
||||
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,7 +397,7 @@ export class AttendanceController {
|
||||
@RequirePermission('attendance:create')
|
||||
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req.user);
|
||||
const canManageAll = this.canManageAllAttendance(req);
|
||||
let userIds: string[];
|
||||
|
||||
if (dto.users) {
|
||||
|
||||
@@ -1,50 +1,223 @@
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
|
||||
|
||||
describe('PermissionGuard', () => {
|
||||
const createContext = (user: unknown) =>
|
||||
({
|
||||
const abilityFactory = new CaslAbilityFactory();
|
||||
|
||||
/** Mock NestJS ExecutionContext with reflector overrides */
|
||||
function createContext(
|
||||
user: unknown,
|
||||
overrides: {
|
||||
isPublic?: boolean;
|
||||
authenticatedOnly?: boolean;
|
||||
permissions?: string[];
|
||||
checkPolicies?: unknown[];
|
||||
} = {},
|
||||
) {
|
||||
const meta = new Map<string, unknown>();
|
||||
if (overrides.isPublic !== undefined) meta.set('isPublic', overrides.isPublic);
|
||||
if (overrides.authenticatedOnly !== undefined)
|
||||
meta.set('authenticatedOnly', overrides.authenticatedOnly);
|
||||
if (overrides.permissions) meta.set('permissions', overrides.permissions);
|
||||
if (overrides.checkPolicies) meta.set('check_policies', overrides.checkPolicies);
|
||||
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
|
||||
getAllAndMerge: jest.fn((key: string) => meta.get(key) ?? []),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never, abilityFactory);
|
||||
return guard.canActivate({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
}) as never;
|
||||
} as never);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @Public / @Authenticated / undeclared
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('denies routes that forgot to declare permissions', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(false),
|
||||
getAllAndMerge: jest.fn().mockReturnValue(undefined),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
|
||||
expect(createContext(undefined)).toBe(false);
|
||||
expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('allows explicitly public routes without a user', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(true);
|
||||
expect(createContext(undefined, { isPublic: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
|
||||
it('allows authenticated-only routes for logged-in users', () => {
|
||||
expect(
|
||||
createContext({ permissions: [], isSuperAdmin: false }, { authenticatedOnly: true }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies authenticated-only routes when no authenticated user is present', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
it('denies authenticated-only routes when no user is present', () => {
|
||||
expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
|
||||
});
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(false);
|
||||
it('does not let controller-level @Authenticated bypass handler permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: false },
|
||||
{ authenticatedOnly: true, permissions: ['user:edit'] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('still enforces policies when @Authenticated and @CheckPolicies coexist', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: false },
|
||||
{
|
||||
authenticatedOnly: true,
|
||||
checkPolicies: [(ability: any) => ability.can('read', 'Student')],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @CheckPolicies passthrough
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('allows pass-through for @CheckPolicies routes with user present', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: false },
|
||||
{ checkPolicies: [(ab: any) => ab.can('read', 'Student')] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies pass-through for @CheckPolicies routes without user', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
checkPolicies: [(ab: any) => ab.can('read', 'Student')],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CASL exact-code authorization (collision-free)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants access to super admin for any permission', () => {
|
||||
expect(
|
||||
createContext({ permissions: [], isSuperAdmin: true }, { permissions: ['student:view'] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants access when user has the exact required permission', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ permissions: ['student:view'] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies access when user lacks the required permission', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ permissions: ['class:delete'] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies access when user has no permissions', () => {
|
||||
expect(
|
||||
createContext({ permissions: [], isSuperAdmin: false }, { permissions: ['student:view'] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies unknown permission codes (no user holds them)', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['unknown:action'], isSuperAdmin: false },
|
||||
{ permissions: ['other:thing'] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('grants exact-code access for custom permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['custom:special'], isSuperAdmin: false },
|
||||
{ permissions: ['custom:special'] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants with OR matching: one of multiple required permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['class:view'], isSuperAdmin: false },
|
||||
{ permissions: ['student:delete', 'class:view'] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// ── Collision regression tests ──
|
||||
|
||||
it('denies bill:export-excel when user only has bill:view', () => {
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer: both would give read Bill — but exact-code check must discriminate
|
||||
expect(ability.can(CaslAction.Read, 'Bill')).toBe(true);
|
||||
// Exact-code check: bill:view user must NOT have bill:export-excel
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('denies bill:confirm when user only has bill:view', () => {
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer: confirm → update, view → read — already distinct at domain level
|
||||
expect(ability.can(CaslAction.Update, 'Bill')).toBe(false);
|
||||
// Exact-code: must also fail
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:confirm'))).toBe(false);
|
||||
});
|
||||
|
||||
it('denies deposit:approve when user only has deposit:edit', () => {
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ['deposit:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer: both map to update — would pass domain check
|
||||
expect(ability.can(CaslAction.Update, 'Deposit')).toBe(true);
|
||||
// Exact-code: must fail — edit is not approve
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
|
||||
});
|
||||
|
||||
it('denies attendance:export when user only has attendance:view', () => {
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ['attendance:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer: both map to read
|
||||
expect(ability.can(CaslAction.Read, 'Attendance')).toBe(true);
|
||||
// Exact-code: must fail
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown code student:nuke does not create domain ability', () => {
|
||||
const ability = abilityFactory.createForUser({
|
||||
permissions: ['student:nuke'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Manage, 'Student')).toBe(false);
|
||||
expect(ability.can(CaslAction.Read, 'Student')).toBe(false);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +1,92 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
|
||||
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
|
||||
import { CHECK_POLICIES_KEY } from '../../authorization/decorators/check-policies.decorator';
|
||||
import type { AuthorizationRequest } from '../../authorization/interfaces';
|
||||
|
||||
/**
|
||||
* 权限守卫 — 默认拒绝策略(安全关键)
|
||||
* Permission guard — deny-by-default (security-critical).
|
||||
*
|
||||
* 当 handler/controller 上不存在 @RequirePermission、@Authenticated 且未标记 @Public 时,守卫拒绝访问。
|
||||
* 所有路由必须显式声明公开、仅登录或所需权限。
|
||||
* When a handler/controller has no @RequirePermission, @Authenticated,
|
||||
* @CheckPolicies, or @Public annotation, the guard denies access.
|
||||
*
|
||||
* ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
|
||||
* 建议配合 lint 规则确保无遗漏。
|
||||
* Authorization is via CASL exact-code matching: each permission code
|
||||
* the user holds is registered as `Access PermissionCode:<code>`.
|
||||
* Checking `@RequirePermission('bill:export-excel')` verifies
|
||||
* `ability.can('access', 'PermissionCode:bill:export-excel')` — a user
|
||||
* with only `bill:view` will NOT pass.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: CaslAbilityFactory,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// 1. @Public() 豁免
|
||||
// 1. @Public() exemption
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
|
||||
const user = request.user;
|
||||
|
||||
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
|
||||
// 2. Read @Authenticated, but only use it as a fallback after checking
|
||||
// more specific permission and policy declarations.
|
||||
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (authenticatedOnly) return !!user;
|
||||
|
||||
// 3. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
// 3. Get required permissions (handler + class merged)
|
||||
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
// 无权限声明且非 @Public/@Authenticated:默认拒绝,避免新增接口意外裸奔
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return false;
|
||||
|
||||
// 4. 从 JWT payload 获取用户权限
|
||||
// 4. Check for @CheckPolicies — defer to PoliciesGuard
|
||||
const hasPolicies = this.reflector.getAllAndMerge<unknown[]>(CHECK_POLICIES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
const hasCheckPolicies = Array.isArray(hasPolicies) && hasPolicies.length > 0;
|
||||
|
||||
// 5. @Authenticated is a fallback only when no more specific authorization
|
||||
// declaration exists. This prevents controller-level @Authenticated from
|
||||
// bypassing handler-level @RequirePermission or @CheckPolicies.
|
||||
if (
|
||||
(!requiredPermissions || requiredPermissions.length === 0) &&
|
||||
!hasCheckPolicies &&
|
||||
authenticatedOnly
|
||||
) {
|
||||
return !!user;
|
||||
}
|
||||
|
||||
// No authorization declaration at any level → deny.
|
||||
if ((!requiredPermissions || requiredPermissions.length === 0) && !hasCheckPolicies) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 6. @CheckPolicies present but no @RequirePermission → let PoliciesGuard handle it
|
||||
if ((!requiredPermissions || requiredPermissions.length === 0) && hasCheckPolicies) {
|
||||
return !!user; // deny unauthenticated, pass-through for PoliciesGuard
|
||||
}
|
||||
|
||||
// 7. User must exist and have permissions array
|
||||
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
|
||||
|
||||
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
return requiredPermissions.some((p) => user.permissions.includes(p));
|
||||
// 8. CASL exact-code check — maps each required code to an exact PermissionCode subject
|
||||
const ability = this.abilityFactory.createForUser(user);
|
||||
|
||||
return requiredPermissions.some((code: string) =>
|
||||
ability.can(CaslAction.Access, permissionCodeSubject(code)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/server/src/authorization/authorization.module.ts
Normal file
11
apps/server/src/authorization/authorization.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
import { PoliciesGuard } from './guards/policies.guard';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
|
||||
exports: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
|
||||
})
|
||||
export class AuthorizationModule {}
|
||||
180
apps/server/src/authorization/authorization.service.spec.ts
Normal file
180
apps/server/src/authorization/authorization.service.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AuthorizationService } from './authorization.service';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { CaslAction, SubjectName } from './casl.constants';
|
||||
import { AuthenticatedUser } from './interfaces';
|
||||
|
||||
describe('AuthorizationService', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
const service = new AuthorizationService(factory);
|
||||
|
||||
const superAdmin: AuthenticatedUser = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
permissions: [],
|
||||
isSuperAdmin: true,
|
||||
roles: ['超管'],
|
||||
};
|
||||
|
||||
const teacher: AuthenticatedUser = {
|
||||
id: 2,
|
||||
username: 'teacher',
|
||||
permissions: ['student:view', 'class:view'],
|
||||
isSuperAdmin: false,
|
||||
roles: ['老师'],
|
||||
};
|
||||
|
||||
const emptyUserReq = (user: AuthenticatedUser) => ({
|
||||
user,
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// HTTP convenience methods
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('can() — HTTP request convenience', () => {
|
||||
it('returns true for super admin on any action/subject', () => {
|
||||
expect(service.can(emptyUserReq(superAdmin), CaslAction.Manage, SubjectName.Student)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(service.can(emptyUserReq(superAdmin), CaslAction.Delete, 'all')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for user with matching permission', () => {
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for user without matching permission', () => {
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Bill)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assert() — HTTP request convenience', () => {
|
||||
it('does not throw for super admin', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(superAdmin), CaslAction.Delete, SubjectName.Room),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw for user with permission', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException for user without permission', () => {
|
||||
expect(() =>
|
||||
service.assert(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Non-HTTP reuse (Agent Tool / background job pattern)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('abilityForRequest()', () => {
|
||||
it('builds ability from request.user', () => {
|
||||
const ability = service.abilityForRequest(emptyUserReq(teacher));
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Class)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAbility() / assertAbility() — non-HTTP usage', () => {
|
||||
const ability = factory.createForUser(teacher);
|
||||
|
||||
it('canAbility returns boolean', () => {
|
||||
expect(service.canAbility(ability, CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(service.canAbility(ability, CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('assertAbility throws on denial', () => {
|
||||
expect(() =>
|
||||
service.assertAbility(ability, CaslAction.Read, SubjectName.Student),
|
||||
).not.toThrow();
|
||||
|
||||
expect(() => service.assertAbility(ability, CaslAction.Delete, SubjectName.Student)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('canAbility and assertAbility work independently of HTTP context', () => {
|
||||
// This is the key Agent Tool pattern:
|
||||
// 1. Build ability from a user object (no req needed)
|
||||
const toolAbility = factory.createForUser({
|
||||
permissions: ['attendance:view', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// 2. Check / assert using the service
|
||||
expect(service.canAbility(toolAbility, CaslAction.Read, SubjectName.Attendance)).toBe(true);
|
||||
expect(service.canAbility(toolAbility, CaslAction.Create, SubjectName.Attendance)).toBe(true);
|
||||
expect(service.canAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance)).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// 3. assertAbility for write operations
|
||||
expect(() =>
|
||||
service.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
service.assertAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// canPermission() / assertPermission() — exact-code permission checks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('canPermission() / assertPermission() — exact-code checks', () => {
|
||||
const ability = factory.createForUser(teacher);
|
||||
|
||||
it('canPermission returns true for owned exact permission code', () => {
|
||||
expect(service.canPermission(ability, 'student:view')).toBe(true);
|
||||
expect(service.canPermission(ability, 'class:view')).toBe(true);
|
||||
});
|
||||
|
||||
it('canPermission returns false for unowned exact permission code', () => {
|
||||
expect(service.canPermission(ability, 'student:delete')).toBe(false);
|
||||
expect(service.canPermission(ability, 'bill:view')).toBe(false);
|
||||
});
|
||||
|
||||
it('canPermission uses Access + permissionCodeSubject, not domain action', () => {
|
||||
// teacher has student:view and class:view. Custom code check is exact.
|
||||
expect(service.canPermission(ability, 'student:export')).toBe(false);
|
||||
});
|
||||
|
||||
it('assertPermission does not throw for owned code', () => {
|
||||
expect(() => service.assertPermission(ability, 'student:view')).not.toThrow();
|
||||
});
|
||||
|
||||
it('assertPermission throws ForbiddenException for unowned code', () => {
|
||||
expect(() => service.assertPermission(ability, 'student:delete')).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('assertPermission error message includes permission code', () => {
|
||||
expect(() => service.assertPermission(ability, 'bill:view')).toThrow(
|
||||
/bill:view/,
|
||||
);
|
||||
});
|
||||
|
||||
it('super admin canPermission returns true for any code', () => {
|
||||
const saAbility = factory.createForUser(superAdmin);
|
||||
expect(service.canPermission(saAbility, 'student:view')).toBe(true);
|
||||
expect(service.canPermission(saAbility, 'custom:action')).toBe(true);
|
||||
expect(service.canPermission(saAbility, 'bill:export-excel')).toBe(true);
|
||||
});
|
||||
|
||||
it('super admin assertPermission never throws', () => {
|
||||
const saAbility = factory.createForUser(superAdmin);
|
||||
expect(() => service.assertPermission(saAbility, 'ghost:action')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
95
apps/server/src/authorization/authorization.service.ts
Normal file
95
apps/server/src/authorization/authorization.service.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces';
|
||||
import { CaslAction, permissionCodeSubject } from './casl.constants';
|
||||
|
||||
/**
|
||||
* Generic authorization service usable both inside and outside of HTTP
|
||||
* request context.
|
||||
*
|
||||
* ### HTTP use
|
||||
* Inject `AuthorizationService` into controllers/services and call
|
||||
* `abilityForRequest(req)` to get the current user's ability.
|
||||
*
|
||||
* ### Non-HTTP use (Agent Tool, background job, etc.)
|
||||
* Build an ability via `abilityFactory.createForUser(user)` and pass it
|
||||
* to `assert` / `can` directly.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthorizationService {
|
||||
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
|
||||
|
||||
/**
|
||||
* Build an {@link AppAbility} for the current HTTP request.
|
||||
*
|
||||
* @param req — Express/NestJS request with `req.user` populated by JWT.
|
||||
*/
|
||||
abilityForRequest(req: AuthorizationRequest): AppAbility {
|
||||
if (!req.user) {
|
||||
throw new ForbiddenException('缺少可信授权身份');
|
||||
}
|
||||
return this.abilityFactory.createForUser(req.user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given ability allows the action on the subject.
|
||||
* Throws `ForbiddenException` on denial.
|
||||
*/
|
||||
assertAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): void {
|
||||
if (!ability.can(action, subject)) {
|
||||
throw new ForbiddenException(
|
||||
`权限不足:${action} ${typeof subject === 'string' ? subject : 'resource'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given ability allows the action on the subject.
|
||||
* Returns boolean — never throws.
|
||||
*/
|
||||
canAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): boolean {
|
||||
return ability.can(action, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given ability allows the exact permission code.
|
||||
* Uses `CaslAction.Access` with `permissionCodeSubject(code)` — the same
|
||||
* mechanism as {@link PermissionGuard}.
|
||||
*
|
||||
* Returns boolean — never throws.
|
||||
*/
|
||||
canPermission(ability: AppAbility, permissionCode: string): boolean {
|
||||
return ability.can(CaslAction.Access, permissionCodeSubject(permissionCode));
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the given ability allows the exact permission code.
|
||||
* Throws `ForbiddenException` on denial.
|
||||
*/
|
||||
assertPermission(ability: AppAbility, permissionCode: string): void {
|
||||
if (!this.canPermission(ability, permissionCode)) {
|
||||
throw new ForbiddenException(
|
||||
`权限不足:缺少权限码 ${permissionCode}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert that the user (from request) can perform an action.
|
||||
* Convenience shorthand — builds ability from request.
|
||||
*/
|
||||
assert(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): void {
|
||||
const ability = this.abilityForRequest(req);
|
||||
this.assertAbility(ability, action, subject);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the user (from request) can perform an action.
|
||||
* Convenience shorthand — builds ability from request.
|
||||
*/
|
||||
can(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): boolean {
|
||||
const ability = this.abilityForRequest(req);
|
||||
return this.canAbility(ability, action, subject);
|
||||
}
|
||||
}
|
||||
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal file
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { subject } from '@casl/ability';
|
||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||
import {
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
permissionCodeSubject,
|
||||
mapPermissionCode,
|
||||
isKnownPermissionCode,
|
||||
} from './casl.constants';
|
||||
|
||||
describe('CaslAbilityFactory', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants manage all for super admin regardless of permissions list', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: [],
|
||||
isSuperAdmin: true,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Class)).toBe(true);
|
||||
});
|
||||
|
||||
it('super admin ability can manage arbitrary subject strings', () => {
|
||||
const ability = factory.createForUser({ permissions: [], isSuperAdmin: true });
|
||||
expect(ability.can(CaslAction.Manage, 'FictionalEntity')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Exact-code permissions (layer 1 — collision-free)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants exact-code access for specific permission codes', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT grant exact-code access for a different code in same domain', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// bill:view user should NOT have bill:export-excel exact code
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT allow export to satisfy view or vice versa', () => {
|
||||
const viewUser = factory.createForUser({
|
||||
permissions: ['attendance:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
const exportUser = factory.createForUser({
|
||||
permissions: ['attendance:export'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(viewUser.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
|
||||
expect(exportUser.can(CaslAction.Access, permissionCodeSubject('attendance:view'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT allow edit to satisfy approve on same resource', () => {
|
||||
const editor = factory.createForUser({
|
||||
permissions: ['deposit:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(editor.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
|
||||
});
|
||||
|
||||
it('custom/unknown codes get exact-code ability but NO domain ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:nuke'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Exact code should be granted
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
|
||||
|
||||
// But no domain ability should exist
|
||||
expect(ability.can(CaslAction.Manage, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('unknown resource custom code gets exact-code but no domain', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['custom:action'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('custom:action'))).toBe(true);
|
||||
// No domain ability for unknown resource
|
||||
const hasAnyDomain = [SubjectName.Student, SubjectName.Bill, SubjectName.Class].some((s) =>
|
||||
ability.can(CaslAction.Read, s),
|
||||
);
|
||||
expect(hasAnyDomain).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Domain-level permissions (layer 2 — for service scoping)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('maps student:view to domain read Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps student:edit to domain update Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
|
||||
});
|
||||
|
||||
it('maps student:create to domain create Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('maps student:delete to domain delete Student', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:delete'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not broaden occupancy:checkin into generic create ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['occupancy:checkin'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Occupancy)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not broaden bill:export-excel into generic read ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['bill:export-excel'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Bill)).toBe(false);
|
||||
// But exact code is separate
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(false);
|
||||
});
|
||||
|
||||
it('cumulative: multiple permissions all apply at both layers', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view', 'room:create', 'bill:delete'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain layer
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Room)).toBe(true);
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Bill)).toBe(true);
|
||||
|
||||
// Exact-code layer
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('room:create'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:delete'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes CASL subject instances instead of treating them as all', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
const student = subject(SubjectName.Student, { id: 1, classId: 7 });
|
||||
|
||||
expect(ability.can(CaslAction.Read, student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Update, student)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let special exact-code permissions grant generic CRUD policies', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:import', 'deposit:approve', 'bill:confirm'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Deposit)).toBe(false);
|
||||
expect(ability.can(CaslAction.Update, SubjectName.Bill)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not broaden archive into generic delete ability', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:archive'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:archive'))).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Unknown permission codes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('silently ignores unknown codes for domain but grants exact-code access', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['unknown:stuff', 'student:view'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
// Domain: only known code applies
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
// Exact: both codes get access
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('unknown:stuff'))).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Profile auto-grant
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('does not grant broad Profile read without an explicit permission', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: [],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Profile)).toBe(false);
|
||||
// No exact-code access either
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('profile:view'))).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// createFromPermissions helper
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('createFromPermissions helper works for tests', () => {
|
||||
const ability = factory.createFromPermissions(['student:view']);
|
||||
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
|
||||
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
|
||||
});
|
||||
|
||||
it('createFromPermissions helper supports isSuperAdmin flag', () => {
|
||||
const ability = factory.createFromPermissions([], true);
|
||||
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standalone mapping function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mapPermissionCode', () => {
|
||||
it('maps known codes', () => {
|
||||
expect(mapPermissionCode('student:view')).toEqual({
|
||||
action: CaslAction.Read,
|
||||
subject: SubjectName.Student,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for unknown resource', () => {
|
||||
expect(mapPermissionCode('ghost:action')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(mapPermissionCode('')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map occupancy:checkin to a generic domain action', () => {
|
||||
expect(mapPermissionCode('occupancy:checkin')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map bill:export-excel to generic read', () => {
|
||||
expect(mapPermissionCode('bill:export-excel')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map bill:generate to generic update', () => {
|
||||
expect(mapPermissionCode('bill:generate')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map deposit:approve to generic update', () => {
|
||||
expect(mapPermissionCode('deposit:approve')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map attendance:export to generic read', () => {
|
||||
expect(mapPermissionCode('attendance:export')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not map archive to generic delete', () => {
|
||||
expect(mapPermissionCode('student:archive')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown action on known resource (student:nuke)', () => {
|
||||
expect(mapPermissionCode('student:nuke')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isKnownPermissionCode', () => {
|
||||
it('recognizes known codes', () => {
|
||||
expect(isKnownPermissionCode('student:view')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown resources', () => {
|
||||
expect(isKnownPermissionCode('ghost:action')).toBe(false);
|
||||
});
|
||||
});
|
||||
61
apps/server/src/authorization/casl-ability.factory.ts
Normal file
61
apps/server/src/authorization/casl-ability.factory.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AbilityBuilder, createMongoAbility, detectSubjectType, MongoAbility } from '@casl/ability';
|
||||
import { CaslAction, mapPermissionCode, permissionCodeSubject } from './casl.constants';
|
||||
import { AppAbility, AppSubject, AuthPrincipal } from './interfaces';
|
||||
|
||||
/**
|
||||
* Builds a CASL {@link AppAbility} instance for a given user.
|
||||
*
|
||||
* This factory is deliberately free of any NestJS execution-context
|
||||
* dependency so it can be reused outside of HTTP (e.g. Agent Tool
|
||||
* execution, background jobs, etc.).
|
||||
*
|
||||
* ## Two-layer ability model
|
||||
*
|
||||
* | Layer | Condition | Grant |
|
||||
* |---|---|---|
|
||||
* | Exact code | Every code in `user.permissions` | `Access PermissionCode:<code>` |
|
||||
* | Domain | Strict CRUD-equivalent codes only | `(create|read|update|delete) Subject` |
|
||||
* | Super admin | `isSuperAdmin === true` | `manage('all')` |
|
||||
*
|
||||
* Unknown/custom codes get only exact-code ability — no domain ability
|
||||
* is inferred.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CaslAbilityFactory {
|
||||
/**
|
||||
* Build ability for a user loaded from the database (or JWT-refreshed).
|
||||
*/
|
||||
createForUser(user: AuthPrincipal): AppAbility {
|
||||
const { can, build } = new AbilityBuilder<MongoAbility<[CaslAction, AppSubject]>>(
|
||||
createMongoAbility,
|
||||
);
|
||||
|
||||
// 1. Super admin → manage everything
|
||||
if (user.isSuperAdmin) {
|
||||
can(CaslAction.Manage, 'all');
|
||||
return build({ detectSubjectType });
|
||||
}
|
||||
|
||||
// 2. For every permission code the user holds:
|
||||
// a) Always add exact-code ability (layer 1)
|
||||
// b) If code is known, also add domain-level ability (layer 2)
|
||||
for (const code of user.permissions ?? []) {
|
||||
// Layer 1 — exact code (always)
|
||||
can(CaslAction.Access, permissionCodeSubject(code));
|
||||
|
||||
// Layer 2 — domain-level (known codes only)
|
||||
const rule = mapPermissionCode(code);
|
||||
if (rule) can(rule.action, rule.subject);
|
||||
}
|
||||
|
||||
return build({ detectSubjectType });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ability from raw permission codes — useful in tests.
|
||||
*/
|
||||
createFromPermissions(permissions: string[], isSuperAdmin = false): AppAbility {
|
||||
return this.createForUser({ permissions, isSuperAdmin });
|
||||
}
|
||||
}
|
||||
180
apps/server/src/authorization/casl.constants.ts
Normal file
180
apps/server/src/authorization/casl.constants.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* CASL authorization constants.
|
||||
*
|
||||
* Maps our existing `{resource}:{action}` permission codes into CASL
|
||||
* `Action` + `Subject` pairs.
|
||||
*
|
||||
* ## Two-layer permission model
|
||||
*
|
||||
* 1. **Exact code** — `Access PermissionCode:<code>` grants the specific
|
||||
* `resource:action` code. Every code a user holds (preset or custom)
|
||||
* gets an exact-code ability. PermissionGuard checks exact codes.
|
||||
*
|
||||
* 2. **Domain level** — only strictly equivalent CRUD codes
|
||||
* (`view|read|create|edit|update|delete`) create broad Subject abilities.
|
||||
* Workflow-specific operations remain exact-code-only.
|
||||
*
|
||||
* Unknown/custom codes (e.g. "student:nuke") get only layer 1, never
|
||||
* layer 2 — no domain ability is inferred.
|
||||
*/
|
||||
|
||||
/** CASL action strings. */
|
||||
export const CaslAction = {
|
||||
Manage: 'manage',
|
||||
Create: 'create',
|
||||
Read: 'read',
|
||||
Update: 'update',
|
||||
Delete: 'delete',
|
||||
/** Check exact permission code (e.g. "bill:export-excel").
|
||||
* Used by PermissionGuard so workflow-specific operations remain distinct. */
|
||||
Access: 'access',
|
||||
} as const;
|
||||
export type CaslAction = (typeof CaslAction)[keyof typeof CaslAction];
|
||||
|
||||
/** Subject names for every entity we protect. */
|
||||
export const SubjectName = {
|
||||
all: 'all',
|
||||
Student: 'Student',
|
||||
Room: 'Room',
|
||||
Occupancy: 'Occupancy',
|
||||
Expense: 'Expense',
|
||||
Bill: 'Bill',
|
||||
Deposit: 'Deposit',
|
||||
Classroom: 'Classroom',
|
||||
Organization: 'Organization',
|
||||
ClassRental: 'ClassRental',
|
||||
Class: 'Class',
|
||||
Schedule: 'Schedule',
|
||||
Attendance: 'Attendance',
|
||||
Dashboard: 'Dashboard',
|
||||
Profile: 'Profile',
|
||||
Notification: 'Notification',
|
||||
OperationLog: 'OperationLog',
|
||||
User: 'User',
|
||||
Role: 'Role',
|
||||
Learning: 'Learning',
|
||||
Exam: 'Exam',
|
||||
Sync: 'Sync',
|
||||
Integration: 'Integration',
|
||||
Department: 'Department',
|
||||
AiConfig: 'AiConfig',
|
||||
} as const;
|
||||
export type SubjectName = (typeof SubjectName)[keyof typeof SubjectName];
|
||||
|
||||
/** Build the exact-code CASL subject string for a permission code. */
|
||||
export function permissionCodeSubject(code: string): string {
|
||||
return `PermissionCode:${code}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain-level action mapping: permission code → CASL action
|
||||
// Used ONLY for the domain layer — not for exact-code access checks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function permissionToAction(permission: string): CaslAction | null {
|
||||
const actionSegment = permission.split(':')[1] ?? permission;
|
||||
|
||||
// Only strictly equivalent CRUD/read permission codes create broad domain
|
||||
// abilities. Workflow-specific operations remain exact-code-only so that,
|
||||
// for example, export cannot satisfy read and approve cannot satisfy update.
|
||||
switch (actionSegment) {
|
||||
case 'create':
|
||||
return CaslAction.Create;
|
||||
case 'view':
|
||||
case 'read':
|
||||
return CaslAction.Read;
|
||||
case 'edit':
|
||||
case 'update':
|
||||
return CaslAction.Update;
|
||||
case 'delete':
|
||||
return CaslAction.Delete;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function permissionToSubject(resource: string): SubjectName | null {
|
||||
switch (resource) {
|
||||
case 'dashboard':
|
||||
return SubjectName.Dashboard;
|
||||
case 'profile':
|
||||
return SubjectName.Profile;
|
||||
case 'notification':
|
||||
return SubjectName.Notification;
|
||||
case 'student':
|
||||
return SubjectName.Student;
|
||||
case 'room':
|
||||
return SubjectName.Room;
|
||||
case 'occupancy':
|
||||
return SubjectName.Occupancy;
|
||||
case 'expense':
|
||||
return SubjectName.Expense;
|
||||
case 'bill':
|
||||
return SubjectName.Bill;
|
||||
case 'deposit':
|
||||
return SubjectName.Deposit;
|
||||
case 'classroom':
|
||||
return SubjectName.Classroom;
|
||||
case 'organization':
|
||||
return SubjectName.Organization;
|
||||
case 'rental':
|
||||
return SubjectName.ClassRental;
|
||||
case 'log':
|
||||
return SubjectName.OperationLog;
|
||||
case 'user':
|
||||
return SubjectName.User;
|
||||
case 'role':
|
||||
return SubjectName.Role;
|
||||
case 'class':
|
||||
return SubjectName.Class;
|
||||
case 'schedule':
|
||||
return SubjectName.Schedule;
|
||||
case 'attendance':
|
||||
return SubjectName.Attendance;
|
||||
case 'learning':
|
||||
return SubjectName.Learning;
|
||||
case 'exam':
|
||||
return SubjectName.Exam;
|
||||
case 'sync':
|
||||
return SubjectName.Sync;
|
||||
case 'integration':
|
||||
return SubjectName.Integration;
|
||||
case 'department':
|
||||
return SubjectName.Department;
|
||||
case 'ai':
|
||||
return SubjectName.AiConfig;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AbilityPermissionRule {
|
||||
action: CaslAction;
|
||||
subject: SubjectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a known `resource:action` permission code to a domain-level
|
||||
* CASL rule, or `null` if the resource segment is unrecognised.
|
||||
*
|
||||
* Domain-level rules are used by services for data-scoping checks.
|
||||
* They are NOT used for exact-code access control — use
|
||||
* {@link permissionCodeSubject} for that.
|
||||
*/
|
||||
export function mapPermissionCode(code: string): AbilityPermissionRule | null {
|
||||
const [resource] = code.split(':');
|
||||
const subject = permissionToSubject(resource ?? '');
|
||||
if (!subject) return null;
|
||||
const action = permissionToAction(code);
|
||||
if (!action) return null;
|
||||
return { action, subject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the permission code is "known" — i.e. the resource maps to a
|
||||
* recognised subject.
|
||||
*/
|
||||
export function isKnownPermissionCode(code: string): boolean {
|
||||
const [resource] = code.split(':');
|
||||
return permissionToSubject(resource ?? '') !== null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { PolicyHandler } from '../interfaces';
|
||||
|
||||
export const CHECK_POLICIES_KEY = 'check_policies';
|
||||
|
||||
/**
|
||||
* Declare CASL-based policy requirements on a route handler or controller.
|
||||
*
|
||||
* Handlers are evaluated with AND semantics — every handler must pass
|
||||
* for the request to be allowed.
|
||||
*
|
||||
* ### Usage — callback handler
|
||||
* ```ts
|
||||
* @CheckPolicies((ability) => ability.can('read', 'Student'))
|
||||
* ```
|
||||
*
|
||||
* ### Usage — class-based handler (prefer this for testability)
|
||||
* ```ts
|
||||
* import { IPolicyHandler } from '../interfaces';
|
||||
*
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) {
|
||||
* return ability.can('read', 'Student');
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @CheckPolicies(new ReadStudentPolicyHandler())
|
||||
* ```
|
||||
*/
|
||||
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
|
||||
SetMetadata(CHECK_POLICIES_KEY, handlers);
|
||||
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { PoliciesGuard } from './policies.guard';
|
||||
import { CaslAbilityFactory } from '../casl-ability.factory';
|
||||
import { AppAbility, IPolicyHandler } from '../interfaces';
|
||||
|
||||
describe('PoliciesGuard', () => {
|
||||
const factory = new CaslAbilityFactory();
|
||||
|
||||
/** Build a mock NestJS ExecutionContext for PoliciesGuard */
|
||||
function createContext(
|
||||
user: unknown,
|
||||
opts: {
|
||||
policyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler> | null;
|
||||
controllerPolicyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler>;
|
||||
isPublic?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const meta = new Map<string, unknown>();
|
||||
if (opts.policyHandlers !== undefined) meta.set('check_policies', opts.policyHandlers);
|
||||
if (opts.isPublic !== undefined) meta.set('isPublic', opts.isPublic);
|
||||
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
|
||||
getAllAndMerge: jest.fn((key: string) => {
|
||||
if (key !== 'check_policies') return [];
|
||||
return [...(opts.policyHandlers ?? []), ...(opts.controllerPolicyHandlers ?? [])];
|
||||
}),
|
||||
};
|
||||
const guard = new PoliciesGuard(reflector as never, factory);
|
||||
return guard.canActivate({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => ({ user }),
|
||||
}),
|
||||
} as never);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// No @CheckPolicies → pass-through
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('passes through when no @CheckPolicies is declared', () => {
|
||||
expect(createContext(undefined)).toBe(true);
|
||||
expect(createContext(null)).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// @Public interaction
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('skips when @Public is declared, even with @CheckPolicies', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
isPublic: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// User absent
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('denies when @CheckPolicies is declared but no user present', () => {
|
||||
expect(
|
||||
createContext(undefined, {
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Callback handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('grants when all policies pass for super admin', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: [], isSuperAdmin: true },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('delete', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants when all policies pass for user with correct permissions', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'),
|
||||
(ability) => ability.can('read', 'Class'),
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when any policy fails (AND semantics)', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [
|
||||
(ability) => ability.can('read', 'Student'), // passes
|
||||
(ability) => ability.can('delete', 'Student'), // fails
|
||||
],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('empty handlers array passes (no policies to check)', () => {
|
||||
expect(
|
||||
createContext({ permissions: ['student:view'], isSuperAdmin: false }, { policyHandlers: [] }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('merges controller and handler policies with AND semantics', () => {
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [(ability) => ability.can('read', 'Student')],
|
||||
controllerPolicyHandlers: [(ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Class-based handlers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('supports class-based policy handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new ReadStudentPolicy()] },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('denies when class-based handler fails', () => {
|
||||
class DeleteStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('delete', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view'], isSuperAdmin: false },
|
||||
{ policyHandlers: [new DeleteStudentPolicy()] },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('mixes callback and class-based handlers', () => {
|
||||
class ReadStudentPolicy implements IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean {
|
||||
return ability.can('read', 'Student');
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
createContext(
|
||||
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
|
||||
{
|
||||
policyHandlers: [new ReadStudentPolicy(), (ability) => ability.can('read', 'Class')],
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Instance-level policy
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('custom instance-level policy: checks specific resource conditions', () => {
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['student:edit'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
const ownsResource = (ab: typeof ability) => ab.can('update', 'Student');
|
||||
|
||||
expect(ownsResource(ability)).toBe(true);
|
||||
});
|
||||
});
|
||||
79
apps/server/src/authorization/guards/policies.guard.ts
Normal file
79
apps/server/src/authorization/guards/policies.guard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator';
|
||||
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
|
||||
import { CaslAbilityFactory } from '../casl-ability.factory';
|
||||
import { AppAbility, AuthorizationRequest, PolicyHandler } from '../interfaces';
|
||||
|
||||
/**
|
||||
* Evaluates CASL policies declared via @CheckPolicies().
|
||||
*
|
||||
* Registered as a global APP_GUARD — runs after JwtAuthGuard and
|
||||
* PermissionGuard in the guard chain. Only activates when a route
|
||||
* carries @CheckPolicies metadata.
|
||||
*
|
||||
* ## Interaction with other guards
|
||||
*
|
||||
* - @Public() → PoliciesGuard skips (same as other guards).
|
||||
* - @CheckPolicies alone (no @RequirePermission) → PermissionGuard
|
||||
* passes through if user is authenticated; PoliciesGuard evaluates.
|
||||
* - @CheckPolicies + @RequirePermission → both guards run independently;
|
||||
* both must pass.
|
||||
*
|
||||
* ## Handler types
|
||||
*
|
||||
* Two forms are supported:
|
||||
*
|
||||
* ```ts
|
||||
* // Callback form
|
||||
* @CheckPolicies((ability) => ability.can('read', 'Student'))
|
||||
*
|
||||
* // Class-based form (testable, NestJS official pattern)
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) { return ability.can('read', 'Student'); }
|
||||
* }
|
||||
* @CheckPolicies(new ReadStudentPolicyHandler())
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class PoliciesGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private abilityFactory: CaslAbilityFactory,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
// @Public() routes skip all authorization
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const handlers = this.reflector.getAllAndMerge<PolicyHandler[]>(CHECK_POLICIES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
// No @CheckPolicies → let other guards decide
|
||||
if (!handlers || handlers.length === 0) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
|
||||
const user = request.user;
|
||||
if (!user) return false;
|
||||
|
||||
const ability = this.abilityFactory.createForUser(user);
|
||||
|
||||
return handlers.every((handler) => this.execHandler(handler, ability));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a policy handler — supports both callback and class-based forms.
|
||||
*/
|
||||
private execHandler(handler: PolicyHandler, ability: AppAbility): boolean {
|
||||
if (typeof handler === 'function') {
|
||||
return handler(ability);
|
||||
}
|
||||
return handler.handle(ability);
|
||||
}
|
||||
}
|
||||
14
apps/server/src/authorization/index.ts
Normal file
14
apps/server/src/authorization/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export { AuthorizationModule } from './authorization.module';
|
||||
export { CaslAbilityFactory } from './casl-ability.factory';
|
||||
export { AuthorizationService } from './authorization.service';
|
||||
export { PoliciesGuard } from './guards/policies.guard';
|
||||
export { CheckPolicies } from './decorators/check-policies.decorator';
|
||||
export { CaslAction, SubjectName, mapPermissionCode } from './casl.constants';
|
||||
export type {
|
||||
AppAbility,
|
||||
AppSubject,
|
||||
AuthenticatedUser,
|
||||
AuthPrincipal,
|
||||
PolicyHandler,
|
||||
IPolicyHandler,
|
||||
} from './interfaces';
|
||||
67
apps/server/src/authorization/interfaces.ts
Normal file
67
apps/server/src/authorization/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { MongoAbility } from '@casl/ability';
|
||||
import { CaslAction } from './casl.constants';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subject type union — all entity classes we protect with CASL.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CASL expects the subject to be either the class constructor or a string.
|
||||
// We use string subjects (SubjectName) for simplicity when no instance is
|
||||
// available, and concrete instance types for per-resource checks.
|
||||
export type AppSubject = string | Record<string, unknown>;
|
||||
|
||||
export type AppAbility = MongoAbility<[CaslAction, AppSubject]>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authenticated user — what the JWT strategy places on `request.user`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: number;
|
||||
username: string;
|
||||
/** Flat list of `resource:action` permission codes. */
|
||||
permissions: string[];
|
||||
/** Whether the user has a super-admin role. */
|
||||
isSuperAdmin: boolean;
|
||||
/** Role names (display/debug only — NEVER used for authorization). */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum authorization principal — the subset of AuthenticatedUser
|
||||
* needed by CaslAbilityFactory and AuthorizationService.
|
||||
*/
|
||||
export type AuthPrincipal = { readonly permissions: readonly string[]; readonly isSuperAdmin: boolean };
|
||||
|
||||
/** Request-like carrier populated only by the trusted authentication layer. */
|
||||
export interface AuthorizationRequest {
|
||||
user?: AuthPrincipal;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy handler types for @CheckPolicies()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Interface for class-based policy handlers.
|
||||
*
|
||||
* Implement this interface in a class to create a testable,
|
||||
* NestJS-official CASL policy handler:
|
||||
*
|
||||
* ```ts
|
||||
* class ReadStudentPolicyHandler implements IPolicyHandler {
|
||||
* handle(ability: AppAbility) {
|
||||
* return ability.can('read', 'Student');
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface IPolicyHandler {
|
||||
handle(ability: AppAbility): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A policy handler — either a callback or a class implementing
|
||||
* {@link IPolicyHandler}.
|
||||
*/
|
||||
export type PolicyHandler = ((ability: AppAbility) => boolean) | IPolicyHandler;
|
||||
@@ -30,15 +30,10 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: RequestUser;
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -48,11 +43,14 @@ export class ClassesController {
|
||||
private readonly service: ClassesService,
|
||||
private readonly logService: OperationLogsService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly authz: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
|
||||
const canManageAll =
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
|
||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
|
||||
@@ -61,7 +59,8 @@ export class ClassesController {
|
||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,45 @@
|
||||
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
AuthorizationService,
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@RequirePermission('dashboard:view')
|
||||
@Controller('dashboard')
|
||||
export class DashboardController {
|
||||
constructor(private service: DashboardService) {}
|
||||
constructor(
|
||||
private service: DashboardService,
|
||||
private readonly authService: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private canManageAllDashboard(user: RequestUser): boolean {
|
||||
private canManageAllDashboard(req: { user: RequestUser }): boolean {
|
||||
const ability = this.authService.abilityForRequest(req);
|
||||
// Legacy: class:edit grants broad dashboard access
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('dashboard:manage') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
ability.can(CaslAction.Manage, SubjectName.Dashboard) ||
|
||||
ability.can(CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user));
|
||||
private getAccessibleClassIds(req: { user: RequestUser }) {
|
||||
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllDashboard(req));
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getStats(await this.getAccessibleClassIds(req.user));
|
||||
return this.service.getStats(await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
@Get('gantt')
|
||||
@@ -60,7 +69,7 @@ export class DashboardController {
|
||||
|
||||
@Get('class-attendance-ranking')
|
||||
async getClassAttendanceRanking(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req.user));
|
||||
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req));
|
||||
}
|
||||
|
||||
@Get('classroom-occupancy')
|
||||
|
||||
@@ -9,10 +9,97 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['ai_config']);
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
|
||||
if (tables.length === 0) {
|
||||
const pkDef = isMySQL
|
||||
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
|
||||
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN';
|
||||
const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP';
|
||||
|
||||
await runner.query(`
|
||||
CREATE TABLE ai_config (
|
||||
${pkDef},
|
||||
singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL',
|
||||
provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI',
|
||||
base_url VARCHAR(500),
|
||||
encrypted_api_key TEXT,
|
||||
api_key_iv VARCHAR(50),
|
||||
api_key_auth_tag VARCHAR(50),
|
||||
key_last4 VARCHAR(4),
|
||||
default_model VARCHAR(100),
|
||||
enabled ${boolType} DEFAULT 0,
|
||||
timeout_ms INT DEFAULT 30000,
|
||||
verified ${boolType} DEFAULT 0,
|
||||
last_tested_at DATETIME,
|
||||
last_test_latency_ms INT,
|
||||
created_at DATETIME NOT NULL DEFAULT ${datetimeFn},
|
||||
updated_at DATETIME NOT NULL DEFAULT ${datetimeFn}
|
||||
)
|
||||
`);
|
||||
|
||||
if (isMySQL) {
|
||||
try {
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)',
|
||||
);
|
||||
} catch {
|
||||
// Index may already exist; MySQL has no IF NOT EXISTS for indexes
|
||||
}
|
||||
} else {
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log('已创建 ai_config 表');
|
||||
} else {
|
||||
// Check for missing columns
|
||||
const table = await runner.getTable('ai_config');
|
||||
const columnNames = new Set(table?.columns.map((c) => c.name) ?? []);
|
||||
|
||||
const desiredColumns: Array<{ name: string; def: string }> = [
|
||||
{ name: 'id', def: '' }, // skip — primary key
|
||||
{ name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" },
|
||||
{ name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" },
|
||||
{ name: 'base_url', def: 'VARCHAR(500)' },
|
||||
{ name: 'encrypted_api_key', def: 'TEXT' },
|
||||
{ name: 'api_key_iv', def: 'VARCHAR(50)' },
|
||||
{ name: 'api_key_auth_tag', def: 'VARCHAR(50)' },
|
||||
{ name: 'key_last4', def: 'VARCHAR(4)' },
|
||||
{ name: 'default_model', def: 'VARCHAR(100)' },
|
||||
{ name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
|
||||
{ name: 'timeout_ms', def: 'INT DEFAULT 30000' },
|
||||
{ name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
|
||||
{ name: 'last_tested_at', def: 'DATETIME' },
|
||||
{ name: 'last_test_latency_ms', def: 'INT' },
|
||||
{ name: 'created_at', def: 'DATETIME' },
|
||||
{ name: 'updated_at', def: 'DATETIME' },
|
||||
];
|
||||
|
||||
for (const col of desiredColumns) {
|
||||
if (col.def && !columnNames.has(col.name)) {
|
||||
await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`);
|
||||
this.logger.log(`已为 ai_config 表添加列: ${col.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillOrganizations(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
178
apps/server/src/database/database-migrations.spec.ts
Normal file
178
apps/server/src/database/database-migrations.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { TestingModule, Test } from '@nestjs/testing';
|
||||
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
|
||||
interface MockColumn {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface MockTable {
|
||||
name: string;
|
||||
columns: MockColumn[];
|
||||
}
|
||||
|
||||
function mockRunner(overrides: {
|
||||
getTables?: MockTable[];
|
||||
getTable?: MockTable;
|
||||
queryError?: Error;
|
||||
} = {}) {
|
||||
const release = jest.fn();
|
||||
const connect = jest.fn();
|
||||
const query = jest.fn();
|
||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||
const getTable = jest.fn().mockResolvedValue(
|
||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
||||
);
|
||||
|
||||
if (overrides.queryError) {
|
||||
query.mockRejectedValue(overrides.queryError);
|
||||
}
|
||||
|
||||
return { release, connect, query, getTables, getTable };
|
||||
}
|
||||
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>) {
|
||||
return {
|
||||
options: { type: 'better-sqlite3' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Type to reach the private ensureAiConfigTable for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
||||
let service: MigrationsPrivate & DatabaseMigrationsService;
|
||||
|
||||
async function bootstrap(runner: ReturnType<typeof mockRunner>) {
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(
|
||||
DatabaseMigrationsService,
|
||||
);
|
||||
}
|
||||
|
||||
it('creates table + index when ai_config does not exist', async () => {
|
||||
const runner = mockRunner({ getTables: [] });
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips ALTER when table exists with all columns', async () => {
|
||||
const allColumns: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
{ name: 'last_test_latency_ms' },
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: allColumns }],
|
||||
getTable: { name: 'ai_config', columns: allColumns },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
// Should NOT issue any ALTER TABLE
|
||||
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
|
||||
(c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
|
||||
);
|
||||
expect(alterCalls).toHaveLength(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds missing column via ALTER TABLE', async () => {
|
||||
// Table has most columns but is missing last_test_latency_ms
|
||||
const missingOne: MockColumn[] = [
|
||||
{ name: 'id' },
|
||||
{ name: 'singleton_key' },
|
||||
{ name: 'provider' },
|
||||
{ name: 'base_url' },
|
||||
{ name: 'encrypted_api_key' },
|
||||
{ name: 'api_key_iv' },
|
||||
{ name: 'api_key_auth_tag' },
|
||||
{ name: 'key_last4' },
|
||||
{ name: 'default_model' },
|
||||
{ name: 'enabled' },
|
||||
{ name: 'timeout_ms' },
|
||||
{ name: 'verified' },
|
||||
{ name: 'last_tested_at' },
|
||||
// last_test_latency_ms missing
|
||||
{ name: 'created_at' },
|
||||
{ name: 'updated_at' },
|
||||
];
|
||||
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'ai_config', columns: missingOne }],
|
||||
getTable: { name: 'ai_config', columns: missingOne },
|
||||
});
|
||||
await bootstrap(runner);
|
||||
await service.ensureAiConfigTable();
|
||||
|
||||
expect(runner.connect).toHaveBeenCalled();
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('releases runner even when query throws', async () => {
|
||||
const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
|
||||
await bootstrap(runner);
|
||||
|
||||
await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — bootstrap failure handling', () => {
|
||||
it('fails application bootstrap when the required ai_config migration fails', async () => {
|
||||
const runner = mockRunner();
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
|
||||
jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
|
||||
const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
|
||||
const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();
|
||||
|
||||
await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
|
||||
expect(backfill).not.toHaveBeenCalled();
|
||||
expect(normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -33,3 +33,4 @@ export { LearningRecord } from './learning-record.entity';
|
||||
export { ResultArchive } from './result-archive.entity';
|
||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||
export { StudentDingMapping } from './student-ding-mapping.entity';
|
||||
export { AiConfig } from '../ai-config/ai-config.entity';
|
||||
|
||||
17
apps/server/src/notifications/dto/notification.dto.spec.ts
Normal file
17
apps/server/src/notifications/dto/notification.dto.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'reflect-metadata';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { NotificationQueryDto } from './notification.dto';
|
||||
|
||||
describe('NotificationQueryDto', () => {
|
||||
it('converts numeric query-string values before integer validation', async () => {
|
||||
const dto = plainToInstance(NotificationQueryDto, {
|
||||
after: '42',
|
||||
limit: '20',
|
||||
});
|
||||
|
||||
await expect(validate(dto)).resolves.toEqual([]);
|
||||
expect(dto.after).toBe(42);
|
||||
expect(dto.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsArray, IsInt } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateNotificationDto {
|
||||
@IsArray()
|
||||
@@ -24,10 +25,12 @@ export class CreateNotificationDto {
|
||||
|
||||
export class NotificationQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
after?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,9 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'department:view', name: '查看部门', group: 'department' },
|
||||
{ code: 'department:edit', name: '编辑部门', group: 'department' },
|
||||
{ code: 'department:delete', name: '删除部门', group: 'department' },
|
||||
{ code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' },
|
||||
{ code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' },
|
||||
{ code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' },
|
||||
];
|
||||
|
||||
export const PRESET_ROLES: Array<{
|
||||
|
||||
@@ -10,6 +10,11 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AuthorizationService,
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
@@ -28,8 +33,8 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
permissions: string[];
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -39,13 +44,15 @@ export class SchedulesController {
|
||||
private readonly service: SchedulesService,
|
||||
private readonly logService: OperationLogsService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
private readonly authService: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private canManageAllSchedules(user: RequestUser): boolean {
|
||||
private canManageAllSchedules(req: { user: RequestUser }): boolean {
|
||||
const ability = this.authService.abilityForRequest(req);
|
||||
// Legacy: class:edit grants broad schedule access for teachers
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('schedule:edit') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
ability.can(CaslAction.Manage, SubjectName.Schedule) ||
|
||||
ability.can(CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +61,7 @@ export class SchedulesController {
|
||||
async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
this.canManageAllSchedules(req),
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
@@ -64,7 +71,7 @@ export class SchedulesController {
|
||||
async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
this.canManageAllSchedules(req),
|
||||
);
|
||||
return this.service.getWeeklyView(query, classIds);
|
||||
}
|
||||
@@ -74,7 +81,7 @@ export class SchedulesController {
|
||||
async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req.user),
|
||||
this.canManageAllSchedules(req),
|
||||
);
|
||||
if (classIds && !classIds.includes(+classId)) return [];
|
||||
return this.service.getClassTeachers(+classId);
|
||||
|
||||
50
apps/server/src/students/student-access-scope.factory.ts
Normal file
50
apps/server/src/students/student-access-scope.factory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
|
||||
import { CaslAction, SubjectName } from '../authorization/casl.constants';
|
||||
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
/**
|
||||
* Constructs a {@link StudentAccessScope} from the trusted server-side
|
||||
* principal carried in {@link AgentToolContext}.
|
||||
*
|
||||
* ## Scope rules
|
||||
*
|
||||
* | Condition | Scope |
|
||||
* |---|---|
|
||||
* | `isSuperAdmin` | `manageAll` |
|
||||
* | `class:edit` domain ability (`Update Class`) | `manageAll` |
|
||||
* | Everything else | `teacher(userId)` |
|
||||
*
|
||||
* These rules mirror the HTTP-layer logic so agent tools are consistent
|
||||
* with the web dashboard. The ability is constructed fresh from the
|
||||
* principal each time — callers cannot pre-forge it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StudentAccessScopeFactory {
|
||||
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
|
||||
|
||||
/**
|
||||
* Build a scope from the authenticated context.
|
||||
*
|
||||
* The ability is constructed from the principal fields inside the context
|
||||
* — every call is a fresh derivation.
|
||||
*/
|
||||
buildScope(context: AgentToolContext): StudentAccessScope {
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
permissions: context.permissions,
|
||||
isSuperAdmin: context.isSuperAdmin,
|
||||
});
|
||||
|
||||
if (context.isSuperAdmin) {
|
||||
return { type: 'manageAll' };
|
||||
}
|
||||
|
||||
// class:edit (Update Class) grants full student scope
|
||||
if (ability.can(CaslAction.Update, SubjectName.Class)) {
|
||||
return { type: 'manageAll' };
|
||||
}
|
||||
|
||||
return { type: 'teacher', userId: context.userId };
|
||||
}
|
||||
}
|
||||
14
apps/server/src/students/student-access-scope.ts
Normal file
14
apps/server/src/students/student-access-scope.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Data-range discriminator for student queries in the agent-tool layer.
|
||||
*
|
||||
* - `manageAll`: The principal may read every student unconditionally
|
||||
* (super admin or equivalent "full student scope" capability).
|
||||
* - `teacher`: The principal is restricted to active students in the
|
||||
* classes where they are a {@link ClassTeacher} (userId must be set).
|
||||
*
|
||||
* NEVER propagate a raw boolean `isSuperAdmin` into new Agent APIs —
|
||||
* use this discriminated union so callers are explicit about intent.
|
||||
*/
|
||||
export type StudentAccessScope =
|
||||
| { readonly type: 'manageAll' }
|
||||
| { readonly type: 'teacher'; readonly userId: number };
|
||||
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers — simulate TypeORM QueryBuilder with raw-column naming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QbMock extends Record<string, jest.Mock> {
|
||||
select: jest.Mock;
|
||||
distinct: jest.Mock;
|
||||
leftJoin: jest.Mock;
|
||||
innerJoin: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
take: jest.Mock;
|
||||
getRawMany: jest.Mock;
|
||||
getRawOne: jest.Mock;
|
||||
setParameter: jest.Mock;
|
||||
}
|
||||
|
||||
function makeQb(rawMany: unknown[] = [], rawOne: unknown | null = null): QbMock {
|
||||
const qb: QbMock = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
setParameter: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue(rawMany),
|
||||
getRawOne: jest.fn().mockResolvedValue(rawOne),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** StudentsService constructor args in order. */
|
||||
function makeService(
|
||||
studentRawMany: unknown[] = [],
|
||||
studentRawOne: unknown | null = null,
|
||||
classStudentRawMany: unknown[] = [],
|
||||
innerJoinOnStudentQb?: (qb: QbMock) => void,
|
||||
): {
|
||||
service: StudentsService;
|
||||
studentQb: QbMock;
|
||||
classStudentQb: QbMock;
|
||||
} {
|
||||
const studentQb = makeQb(studentRawMany, studentRawOne ?? studentRawMany[0] ?? null);
|
||||
const classStudentQb = makeQb(classStudentRawMany);
|
||||
|
||||
if (innerJoinOnStudentQb) innerJoinOnStudentQb(studentQb);
|
||||
|
||||
const studentRepo = { createQueryBuilder: jest.fn().mockReturnValue(studentQb) };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn().mockReturnValue(classStudentQb) };
|
||||
|
||||
const service = new StudentsService(
|
||||
studentRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never, // classRepo
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // organizationRepo
|
||||
);
|
||||
|
||||
return { service, studentQb, classStudentQb };
|
||||
}
|
||||
|
||||
const manageAll: StudentAccessScope = { type: 'manageAll' };
|
||||
const teacher: StudentAccessScope = { type: 'teacher', userId: 42 };
|
||||
|
||||
// TypeORM getRawMany/getRawOne uses snake_case column aliases
|
||||
const sampleRaw = {
|
||||
student_id: 1,
|
||||
student_name: '张三',
|
||||
student_student_no: 'S001',
|
||||
student_gender: '男',
|
||||
student_status: 'active',
|
||||
student_organization_id: 10,
|
||||
organization_name: '杭州校区',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
id: 1,
|
||||
name: '张三',
|
||||
studentNo: 'S001',
|
||||
gender: '男',
|
||||
status: 'active',
|
||||
organizationId: 10,
|
||||
organizationName: '杭州校区',
|
||||
classIds: [5],
|
||||
};
|
||||
|
||||
// classStudentRepo raw aliases: cs.studentId → cs_student_id, cs.classId → cs_class_id
|
||||
const classStudentRaw = [
|
||||
{ cs_student_id: 1, cs_class_id: 5 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StudentsService — agent-safe query APIs', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — scope enforcement in SQL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — scope enforcement in SQL', () => {
|
||||
it('manageAll scope does NOT add teacher/class restrictions', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).not.toContain('class_teacher');
|
||||
expect(innerJoinArgs).not.toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope adds class_student join with class_teacher subquery', async () => {
|
||||
const { service, studentQb } = makeService([], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope returns empty array when no students match', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentSearchStudents(teacher, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — classId intersection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — classId intersection', () => {
|
||||
it('classId triggers class_student join for manageAll', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { classId: 5 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('scopeClassId');
|
||||
});
|
||||
|
||||
it('classId ANDs with teacher scope (intersection, not widening)', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, { classId: 3 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
|
||||
// classId adds an andWhere, not replacing the teacher join
|
||||
const andWhereArgs = studentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereArgs).toContain('scopeClassId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — field whitelist
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — field whitelist', () => {
|
||||
it('select list excludes sensitive fields', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const selectCalls = studentQb.select.mock.calls.flat().join(' ');
|
||||
const forbidden = ['phone', 'idNumber', 'emergencyContact', 'emergencyPhone'];
|
||||
for (const field of forbidden) {
|
||||
expect(selectCalls).not.toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps raw columns to formatted output', async () => {
|
||||
const { service } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
expect(result).toEqual([expectedOutput]);
|
||||
});
|
||||
|
||||
it('output never contains phone/idNumber', async () => {
|
||||
const { service } = makeService([{ ...sampleRaw, student_phone: '13800138000' }], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
if (result.length > 0) {
|
||||
const keys = Object.keys(result[0]);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — limit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — limit', () => {
|
||||
it('clamps limit to max 50', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 200 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('clamps limit to min 1', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 0 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('defaults limit to 20 when not specified', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
expect(studentQb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — filters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — filters', () => {
|
||||
it('keyword goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { keyword: '张三' });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('LIKE');
|
||||
expect(andWhereStr).toContain('keyword');
|
||||
});
|
||||
|
||||
it('organizationId goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { organizationId: 10 });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('orgId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentGetStudentBasic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentGetStudentBasic', () => {
|
||||
it('returns formatted result for student in scope', async () => {
|
||||
// getRawOne returns first element
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('returns null when student not in scope', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentGetStudentBasic(teacher, 999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces teacher scope at SQL level via subquery', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], sampleRaw, []);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('output whitelist excludes sensitive fields', async () => {
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
const keys = Object.keys(result!);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
expect(keys).not.toContain('emergencyContact');
|
||||
expect(keys).not.toContain('emergencyPhone');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P1-3: classIds scope enforcement (second query re-applies teacher filter)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P1-3: classIds second query enforces teacher scope', () => {
|
||||
it('agentSearchStudents teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic manageAll: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(manageAll, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-3: DISTINCT to prevent duplicate students from teacher multi-class join
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-3: DISTINCT in main student query', () => {
|
||||
it('agentSearchStudents teacher scope calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope also calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,14 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('students')
|
||||
export class StudentsController {
|
||||
@@ -35,13 +41,14 @@ export class StudentsController {
|
||||
private service: StudentsService,
|
||||
private logService: OperationLogsService,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
private authz: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
|
||||
private canManageAllStudents(req: AuthenticatedRequest): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('student:edit') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Student) ||
|
||||
// Legacy: class:edit grants broad student access for teacher scoping
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +59,11 @@ export class StudentsController {
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('includeArchived') includeArchived: string | undefined,
|
||||
@Query('organizationId') organizationId: string | undefined,
|
||||
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
return this.service.findAll(
|
||||
{
|
||||
@@ -78,7 +85,7 @@ export class StudentsController {
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
const students = await this.service.findAll(
|
||||
{ includeArchived: includeArchived === 'true' },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScopeFactory } from './student-access-scope.factory';
|
||||
import { StudentsController } from './students.controller';
|
||||
|
||||
@Module({
|
||||
@@ -21,7 +22,7 @@ import { StudentsController } from './students.controller';
|
||||
]),
|
||||
],
|
||||
controllers: [StudentsController],
|
||||
providers: [StudentsService],
|
||||
exports: [StudentsService],
|
||||
providers: [StudentsService, StudentAccessScopeFactory],
|
||||
exports: [StudentsService, StudentAccessScopeFactory],
|
||||
})
|
||||
export class StudentsModule {}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
@Injectable()
|
||||
export class StudentsService {
|
||||
@@ -224,7 +225,7 @@ export class StudentsService {
|
||||
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
|
||||
if (row.supervisor) updates.supervisor = row.supervisor;
|
||||
if (row.organizationId) updates.organizationId = row.organizationId;
|
||||
await this.repo.update(student.id, updates as Partial<Student>);
|
||||
await this.repo.update(student.id, updates);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
@@ -302,4 +303,228 @@ export class StudentsService {
|
||||
|
||||
return { student, enrollments: comparison };
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Agent-safe query APIs — SQL-level scope + field whitelist
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Whitelisted output type for agent student searches.
|
||||
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
|
||||
*/
|
||||
private static readonly AGENT_STUDENT_SELECT = [
|
||||
'student.id',
|
||||
'student.name',
|
||||
'student.studentNo',
|
||||
'student.gender',
|
||||
'student.status',
|
||||
'student.organizationId',
|
||||
'organization.name',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Search students with SQL-enforced scope, field whitelist, and limit.
|
||||
*
|
||||
* @param scope — data-range discriminator (manageAll or teacher).
|
||||
* @param query — optional keyword, classId, organizationId, limit.
|
||||
* @returns formatted whitelist-only results with classIds.
|
||||
*/
|
||||
async agentSearchStudents(
|
||||
scope: StudentAccessScope,
|
||||
query?: {
|
||||
keyword?: string;
|
||||
classId?: number;
|
||||
organizationId?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
studentNo: string;
|
||||
gender: string;
|
||||
status: string;
|
||||
organizationId: number;
|
||||
organizationName: string;
|
||||
classIds: number[];
|
||||
}[]
|
||||
> {
|
||||
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
|
||||
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('student')
|
||||
.distinct(true)
|
||||
.select([
|
||||
'student.id',
|
||||
'student.name',
|
||||
'student.studentNo',
|
||||
'student.gender',
|
||||
'student.status',
|
||||
'student.organizationId',
|
||||
'organization.name',
|
||||
])
|
||||
.leftJoin('student.organization', 'organization');
|
||||
|
||||
// ---- Scope enforcement ----
|
||||
this.applyStudentScope(qb, scope, query?.classId);
|
||||
|
||||
// ---- Filters ----
|
||||
if (query?.keyword) {
|
||||
qb.andWhere(
|
||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||
{ keyword: `%${query.keyword}%` },
|
||||
);
|
||||
}
|
||||
if (query?.organizationId) {
|
||||
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
||||
}
|
||||
|
||||
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
||||
|
||||
const rows: Record<string, unknown>[] = await qb.getRawMany();
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Second bounded query: classIds only for the returned student ids.
|
||||
// For teacher scope, the class filter MUST be re-applied so the
|
||||
// teacher only sees classIds they are assigned to.
|
||||
const studentIds = rows.map((r) => r.student_id as number);
|
||||
const csQb = this.classStudentRepo
|
||||
.createQueryBuilder('cs')
|
||||
.select(['cs.studentId', 'cs.classId'])
|
||||
.where('cs.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('cs.status = :status', { status: 'active' });
|
||||
|
||||
if (scope.type === 'teacher') {
|
||||
csQb.andWhere(
|
||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
{ scopeTeacherUserId: scope.userId },
|
||||
);
|
||||
}
|
||||
|
||||
const classRows = await csQb.getRawMany();
|
||||
|
||||
const classMap = new Map<number, number[]>();
|
||||
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
|
||||
const sid = cr.cs_student_id;
|
||||
if (!classMap.has(sid)) classMap.set(sid, []);
|
||||
classMap.get(sid)!.push(cr.cs_class_id);
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.student_id as number,
|
||||
name: r.student_name as string,
|
||||
studentNo: (r.student_student_no as string) ?? '',
|
||||
gender: (r.student_gender as string) ?? '',
|
||||
status: r.student_status as string,
|
||||
organizationId: r.student_organization_id as number,
|
||||
organizationName: (r.organization_name as string) ?? '',
|
||||
classIds: classMap.get(r.student_id as number) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single student basic info with SQL-enforced scope + whitelist.
|
||||
* Returns `null` for students out of scope or non-existent (no leak).
|
||||
*/
|
||||
async agentGetStudentBasic(
|
||||
scope: StudentAccessScope,
|
||||
studentId: number,
|
||||
): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
studentNo: string;
|
||||
gender: string;
|
||||
status: string;
|
||||
organizationId: number;
|
||||
organizationName: string;
|
||||
classIds: number[];
|
||||
} | null> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('student')
|
||||
.select([
|
||||
'student.id',
|
||||
'student.name',
|
||||
'student.studentNo',
|
||||
'student.gender',
|
||||
'student.status',
|
||||
'student.organizationId',
|
||||
'organization.name',
|
||||
])
|
||||
.leftJoin('student.organization', 'organization')
|
||||
.where('student.id = :studentId', { studentId });
|
||||
|
||||
this.applyStudentScope(qb, scope);
|
||||
|
||||
const row = await qb.getRawOne();
|
||||
if (!row) return null;
|
||||
|
||||
// For teacher scope, re-apply class filter so teacher only sees
|
||||
// classIds they are assigned to (not ALL active classIds of the student).
|
||||
const csQb = this.classStudentRepo
|
||||
.createQueryBuilder('cs')
|
||||
.select(['cs.classId'])
|
||||
.where('cs.studentId = :studentId', { studentId })
|
||||
.andWhere('cs.status = :status', { status: 'active' });
|
||||
|
||||
if (scope.type === 'teacher') {
|
||||
csQb.andWhere(
|
||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
{ scopeTeacherUserId: scope.userId },
|
||||
);
|
||||
}
|
||||
|
||||
const classRows = await csQb.getRawMany();
|
||||
|
||||
return {
|
||||
id: row.student_id as number,
|
||||
name: row.student_name as string,
|
||||
studentNo: (row.student_student_no as string) ?? '',
|
||||
gender: (row.student_gender as string) ?? '',
|
||||
status: row.student_status as string,
|
||||
organizationId: row.student_organization_id as number,
|
||||
organizationName: (row.organization_name as string) ?? '',
|
||||
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply data-range scope to a student QueryBuilder.
|
||||
*
|
||||
* - `manageAll`: no restriction.
|
||||
* - `teacher`: INNER JOIN ClassStudent → active students in the
|
||||
* teacher's assigned classes (via ClassTeacher).
|
||||
* - When `classId` is provided, it is ANDed with the scope
|
||||
* (intersection) — the model cannot widen access.
|
||||
*/
|
||||
private applyStudentScope(
|
||||
qb: ReturnType<typeof this.repo.createQueryBuilder>,
|
||||
scope: StudentAccessScope,
|
||||
classId?: number,
|
||||
): void {
|
||||
if (scope.type === 'manageAll') {
|
||||
if (classId != null) {
|
||||
qb.innerJoin(
|
||||
'class_student',
|
||||
'cs_scope',
|
||||
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
|
||||
{ scopeClassId: classId, scopeCsStatus: 'active' },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Teacher scope: active students in teacher's assigned classes
|
||||
const teacherClause =
|
||||
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
|
||||
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
|
||||
|
||||
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
|
||||
scopeTeacherUserId: scope.userId,
|
||||
scopeCsStatus: 'active',
|
||||
});
|
||||
|
||||
if (classId != null) {
|
||||
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
282
docs/superpowers/plans/casl-migration.md
Normal file
282
docs/superpowers/plans/casl-migration.md
Normal file
@@ -0,0 +1,282 @@
|
||||
# CASL 授权体系迁移文档
|
||||
|
||||
## 概述
|
||||
|
||||
NestJS 后端授权已从基于 `permissions.includes()` 的字符串匹配迁移到 CASL(`@casl/ability`)基于能力的 ABAC 授权模型。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AuthorizationModule (@Global) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────┐ │
|
||||
│ │ CaslAbilityFactory │ │ AuthorizationService │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ createForUser(user) │ │ can(req, action, subj) │ │
|
||||
│ │ → AppAbility │ │ assert(req, ...) │ │
|
||||
│ │ │ │ canAbility(ab, ...) │ │
|
||||
│ └──────────┬───────────┘ │ assertAbility(ab, ...) │ │
|
||||
│ │ └────────────────────────┘ │
|
||||
│ ┌──────────▼───────────┐ ┌────────────────────────┐ │
|
||||
│ │ casl.constants.ts │ │ PoliciesGuard │ │
|
||||
│ │ mapPermissionCode() │ │ @CheckPolicies(…) │ │
|
||||
│ │ CaslAction/Subject │ └────────────────────────┘ │
|
||||
│ └──────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 核心类型
|
||||
|
||||
|概念|类型|说明|
|
||||
|---|---|---|
|
||||
|Action|`CaslAction`|`'manage' \| 'create' \| 'read' \| 'update' \| 'delete'`|
|
||||
|Subject|`SubjectName`|`'Student' \| 'Room' \| 'Class' \| …` (所有实体)|
|
||||
|Ability|`AppAbility`|`MongoAbility<[CaslAction, AppSubject]>`|
|
||||
|User|`AuthenticatedUser`|`{ id, username, permissions, isSuperAdmin, roles }`|
|
||||
|
||||
### 权限码映射
|
||||
|
||||
|旧权限码|CASL Action|CASL Subject|
|
||||
|---|---|---|
|
||||
|`student:view`|`read`|`Student`|
|
||||
|`student:create` / `student:import`|`create`|`Student`|
|
||||
|`student:edit`|`update`|`Student`|
|
||||
|`student:delete`|`delete`|`Student`|
|
||||
|`student:export`|`read`|`Student`|
|
||||
|`occupancy:checkin`|`create`|`Occupancy`|
|
||||
|`occupancy:checkout`|`update`|`Occupancy`|
|
||||
|`occupancy:transfer`|`update`|`Occupancy`|
|
||||
|`bill:generate` / `bill:confirm`|`update`|`Bill`|
|
||||
|`bill:export-excel` / `bill:export-pdf`|`read`|`Bill`|
|
||||
|`deposit:approve`|`update`|`Deposit`|
|
||||
|`sync:trigger` / `integration:trigger`|`update`|`Sync` / `Integration`|
|
||||
|
||||
完整映射见 `apps/server/src/authorization/casl.constants.ts`。
|
||||
|
||||
### 超管处理
|
||||
|
||||
`isSuperAdmin === true` → `ability.can('manage', 'all')` → 所有操作全部放行。
|
||||
|
||||
### 未知权限码处理
|
||||
|
||||
未知/无法映射的权限码(如 `ghost:action`)→ **不产生任何 CASL ability → deny-by-default**。用户对象上仍保留完整的 `permissions` 数组用于前端菜单/日志,但授权判断拒绝未知码。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 新增文件
|
||||
|
||||
|文件|说明|
|
||||
|---|---|
|
||||
|`apps/server/src/authorization/casl.constants.ts`|Action/Subject 定义、权限码映射函数|
|
||||
|`apps/server/src/authorization/interfaces.ts`|`AppAbility`, `AuthenticatedUser`, `PolicyHandler` 类型|
|
||||
|`apps/server/src/authorization/casl-ability.factory.ts`|CASL Ability 构建工厂|
|
||||
|`apps/server/src/authorization/authorization.service.ts`|通用授权服务(HTTP + 非 HTTP)|
|
||||
|`apps/server/src/authorization/authorization.module.ts`|@Global 模块|
|
||||
|`apps/server/src/authorization/index.ts`|桶导出|
|
||||
|`apps/server/src/authorization/decorators/check-policies.decorator.ts`|`@CheckPolicies()` 装饰器|
|
||||
|`apps/server/src/authorization/guards/policies.guard.ts`|`PoliciesGuard` CASL 策略守卫|
|
||||
|`apps/server/src/authorization/casl-ability.factory.spec.ts`|工厂测试(17 用例)|
|
||||
|`apps/server/src/authorization/authorization.service.spec.ts`|服务测试(12 用例)|
|
||||
|`apps/server/src/authorization/guards/policies.guard.spec.ts`|策略守卫测试(7 用例)|
|
||||
|
||||
### 修改文件
|
||||
|
||||
|文件|变更|
|
||||
|---|---|
|
||||
|`apps/server/src/auth/guards/permission.guard.ts`|注入 `CaslAbilityFactory`,用 `ability.can()` 替代 `permissions.includes()`|
|
||||
|`apps/server/src/auth/guards/permission.guard.spec.ts`|新增 CASL 授权测试(7 用例)|
|
||||
|`apps/server/src/app.module.ts`|导入 `AuthorizationModule`|
|
||||
|`apps/server/package.json`|新增 `@casl/ability` 依赖|
|
||||
|`apps/server/src/students/students.controller.ts`|注入 `AuthorizationService`,用 CASL 替代 `isSuperAdmin` 检查|
|
||||
|`apps/server/src/classes/classes.controller.ts`|同上|
|
||||
|`apps/server/src/attendance/attendance.controller.ts`|同上,修复测试兼容|
|
||||
|`apps/server/src/schedules/schedules.controller.ts`|同上|
|
||||
|`apps/server/src/dashboard/dashboard.controller.ts`|同上|
|
||||
|
||||
## Agent Tool 使用指南
|
||||
|
||||
CASL 授权服务**不依赖 HTTP ExecutionContext**,可在 Agent Tool、后台任务、CLI 等场景直接使用:
|
||||
|
||||
```typescript
|
||||
import { CaslAbilityFactory } from './authorization';
|
||||
import { AuthorizationService } from './authorization';
|
||||
import { CaslAction, SubjectName } from './authorization';
|
||||
|
||||
// 方式 1: 只构建 Ability
|
||||
const factory = app.get(CaslAbilityFactory);
|
||||
const ability = factory.createForUser({
|
||||
permissions: ['attendance:view', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
});
|
||||
|
||||
if (ability.can(CaslAction.Read, SubjectName.Attendance)) {
|
||||
// 执行考勤查询
|
||||
}
|
||||
|
||||
// 方式 2: 使用 AuthorizationService
|
||||
const authz = app.get(AuthorizationService);
|
||||
const toolAbility = factory.createForUser(user);
|
||||
authz.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance);
|
||||
// 如果无权限,抛出 ForbiddenException
|
||||
|
||||
// 方式 3: 通过 request-like 对象(适用于有 request 模拟的场景)
|
||||
authz.assert(
|
||||
{ user: { permissions: ['student:view'], isSuperAdmin: false } },
|
||||
CaslAction.Read,
|
||||
SubjectName.Student,
|
||||
);
|
||||
```
|
||||
|
||||
推荐 Agent Tool 使用 **方式 1+2**:先用 `factory.createForUser(user)` 构建 ability,再用 `service.canAbility/assertAbility` 检查。这种方式完全独立于 NestJS 请求生命周期。
|
||||
|
||||
## 测试命令与结果
|
||||
|
||||
```bash
|
||||
cd apps/server
|
||||
|
||||
# 全部测试
|
||||
npx jest --no-coverage
|
||||
# 结果: 27 passed, 127 passed, 3 skipped
|
||||
|
||||
# 仅 CASL 相关测试
|
||||
npx jest --no-coverage authorization/ auth/guards/permission.guard.spec.ts
|
||||
# 结果: 54 passed
|
||||
|
||||
# 类型检查
|
||||
npx tsc -p tsconfig.build.json --noEmit
|
||||
# 结果: clean (无错误)
|
||||
```
|
||||
|
||||
## 遗留风险 / TODO
|
||||
|
||||
1. **`class:edit` 宽泛授权**(ponytail 标记):拥有 `class:edit` 权限的教师目前获得全量学生/排课/考勤管理权限。理想情况下应通过 CASL conditions 限制为仅自己班级的学生。当前数据模型(需查询 `class_teacher` 关联表确定 scope)无法直接在 CASL Ability 中表达。**未降低现有权限**,保留现状并加 TODO。
|
||||
|
||||
2. **前端权限守卫**:前端 `PermissionRoute` 组件(`apps/admin/src/auth/permission-store.ts`)仍然使用 `permissions.includes()` 检查。不影响安全性(后端是真实授权源),但可在后续迭代中统一。
|
||||
|
||||
3. **操作日志中的权限上下文**:当前操作日志记录仍使用 `user.permissions` 数组。CASL 迁移未改变日志格式。
|
||||
|
||||
4. **`dashboard:manage` 权限**:`dashboard` subject 在 preset permissions 中仅有 `dashboard:view`,但 dashboard.controller 检查了 `dashboard:manage`。CASL 映射将 `dashboard:manage` 的未知 action 映射为 `read`(保守),非 super_admin 用户理论上无法通过此检查。但实际上 controller 的权限守卫用的是 `@RequirePermission('dashboard:view')`,CASL 映射正常。`dashboard:manage` 仅出现在内部方法 `canManageAllDashboard` 的 permissions.includes 检查中,现已被 CASL 替代。
|
||||
|
||||
5. **构建验证**:`npx nest build` 未在迁移中执行(jest + tsc 已覆盖编译和类型检查)。Docker 部署前建议执行一次完整构建。
|
||||
|
||||
## Agent Tool 只读数据安全执行框架
|
||||
|
||||
### 架构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ AgentToolsModule (NON-HTTP — no controller) │
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌─────────────────────────────┐ │
|
||||
│ │ AgentToolRegistry │ │ AgentToolExecutor │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ listAvailable(ctx) │ │ execute(name, input, ctx) │ │
|
||||
│ │ → ToolDef[] │ │ 1. assertPermission │ │
|
||||
│ │ │ │ 2. tool.validate(input) │ │
|
||||
│ │ Filtered by exact- │ │ 3. tool.execute(…) │ │
|
||||
│ │ code permission │ │ 4. audit (best-effort) │ │
|
||||
│ └──────────────────────┘ └─────────────────────────────┘ │
|
||||
│ │
|
||||
│ Built-in tools: │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ search_students (student:view) │ │
|
||||
│ │ get_student_basic (student:view) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 安全保证
|
||||
|
||||
1. **动态暴露(listAvailable)**:只暴露 principal 拥有 exact permission 的 Tool。
|
||||
2. **执行时二次授权(execute)**:不依赖 `listAvailable`,`execute` 再次调用 `assertPermission`。
|
||||
3. **数据库 WHERE 数据范围**:`StudentAccessScope` 使用 TypeORM QueryBuilder + EXISTS 子查询,在 SQL 层面限制数据范围:
|
||||
- `manageAll`:超管或全量学生范围 → 无限制
|
||||
- `teacher`:仅 `ClassTeacher.userId` 分配班级的 active `ClassStudent`
|
||||
4. **输出白名单**:所有 Tool 输出仅限 `id, name, studentNo, gender, status, organizationId, organizationName, classIds`。`phone`, `idNumber`, `emergencyContact`, `emergencyPhone` 不进入查询 SELECT。
|
||||
5. **审计**:模块 `AI Agent Tool`,记录 tool 名、状态(success/denied/failed)、userId/username(来自 context principal)。审计写入失败不影响 Tool 调用结果。
|
||||
6. **审计脱敏**:审计 detail 绝不包含 raw input、phone、idNumber 等敏感值。
|
||||
|
||||
### SDK 适配伪代码(provider-neutral)
|
||||
|
||||
任何 LLM SDK(Vercel AI、LangChain、OpenAI function calling 等)都可以适配:
|
||||
|
||||
```typescript
|
||||
// 1. 获取 NestJS 容器中的 Registry 和 Executor
|
||||
const registry = app.get(AgentToolRegistry);
|
||||
const executor = app.get(AgentToolExecutor);
|
||||
const authz = app.get(AuthorizationService);
|
||||
|
||||
// 2. 构建可信 AgentToolContext(userId/permissions 来自服务端认证)
|
||||
const ability = abilityFactory.createForUser(authenticatedUser);
|
||||
const ctx: AgentToolContext = {
|
||||
userId: authenticatedUser.id,
|
||||
username: authenticatedUser.username,
|
||||
permissions: authenticatedUser.permissions,
|
||||
isSuperAdmin: authenticatedUser.isSuperAdmin,
|
||||
ability,
|
||||
};
|
||||
|
||||
// 3. 动态暴露工具列表(给 LLM SDK 的 tools/functions 定义)
|
||||
const availableTools = registry.listAvailable(ctx);
|
||||
const sdkTools = availableTools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
// … 根据 tool 自定义参数 schema
|
||||
}));
|
||||
|
||||
// 4. 执行 Tool 调用(带输入校验 + 二次授权 + 审计)
|
||||
const result = await executor.execute("search_students", rawInput, ctx);
|
||||
// result.status: 'success' | 'denied' | 'failed'
|
||||
// result.result: 白名单后的数据(仅 success 时)
|
||||
// result.error: 错误信息(denied/failed 时)
|
||||
```
|
||||
|
||||
### 新增文件清单
|
||||
|
||||
|文件|说明|
|
||||
|---|---|
|
||||
|`src/agent-tools/agent-tool.types.ts`|AgentToolContext, ToolDef, ToolExecutionResult 类型定义|
|
||||
|`src/agent-tools/agent-tool.registry.ts`|Tool 注册 + 按权限过滤暴露|
|
||||
|`src/agent-tools/agent-tool.executor.ts`|执行时二次授权 + 输入校验 + 审计|
|
||||
|`src/agent-tools/tools/search-students.tool.ts`|search_students Tool|
|
||||
|`src/agent-tools/tools/get-student-basic.tool.ts`|get_student_basic Tool|
|
||||
|`src/agent-tools/agent-tools.module.ts`|NestJS 模块(不暴露 HTTP endpoint)|
|
||||
|`src/agent-tools/index.ts`|桶导出|
|
||||
|`src/students/student-access-scope.ts`|StudentAccessScope 数据范围类型|
|
||||
|`src/agent-tools/agent-tool.executor.spec.ts`|执行器测试(19 用例)|
|
||||
|`src/agent-tools/tools/search-students.tool.spec.ts`|search_students 测试(12 用例)|
|
||||
|`src/agent-tools/tools/get-student-basic.tool.spec.ts`|get_student_basic 测试(10 用例)|
|
||||
|`src/students/students.agent-api.spec.ts`|agent-safe API 测试(17 用例)|
|
||||
|
||||
### 修改文件
|
||||
|
||||
|文件|变更|
|
||||
|---|---|
|
||||
|`src/authorization/authorization.service.ts`|新增 `canPermission` / `assertPermission` 方法|
|
||||
|`src/authorization/authorization.service.spec.ts`|新增 8 个 exact-code 权限检查测试|
|
||||
|`src/students/students.service.ts`|新增 `agentSearchStudents` / `agentGetStudentBasic` + `applyStudentScope`|
|
||||
|`src/app.module.ts`|导入 `AgentToolsModule`|
|
||||
|`docs/superpowers/plans/casl-migration.md`|本文档新增 Agent Tool 章节|
|
||||
|
||||
### 测试结果
|
||||
|
||||
```bash
|
||||
npx jest --no-coverage --forceExit
|
||||
# 结果: 31 suites, 223 passed, 3 skipped
|
||||
|
||||
npx tsc -p tsconfig.build.json --noEmit
|
||||
# 结果: clean
|
||||
|
||||
npx nest build
|
||||
# 结果: clean
|
||||
|
||||
npx eslint --no-fix src/agent-tools/**/*.ts src/students/student-access-scope.ts src/authorization/authorization.service.ts
|
||||
# 结果: clean
|
||||
```
|
||||
|
||||
### 剩余风险
|
||||
|
||||
1. **classIds 聚合为第二查询**:对大量结果,批量聚合 classIds 的第二条查询使用 `IN (:...ids)`,在 MySQL 中 IN 子句过大时有性能上限(当前 limit 50 安全)。
|
||||
2. **`manageAll` 判定**:当前 `manageAll` = `isSuperAdmin`。若未来有非超管的全量学生范围角色,需扩展 `StudentAccessScope` 的 `manageAll` 判定逻辑。
|
||||
3. **Tool 扩展**:当前仅 `student:view` 的两个 Tool。新增 Tool 只需实现 `ToolDef` 并注册到 `AgentToolsModule`,无需修改框架代码。
|
||||
48
package-lock.json
generated
48
package-lock.json
generated
@@ -56,6 +56,7 @@
|
||||
"version": "0.0.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@casl/ability": "^7.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
@@ -1039,6 +1040,18 @@
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/@casl/ability": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@casl/ability/-/ability-7.0.1.tgz",
|
||||
"integrity": "sha512-krOW1zQGEie5lH6fpryrcsPvc5r2pe5Vn5xbthuzc5X9gwzHIyZgGGgw9dMoSLHXdjB4BIVxGG/HBkG+1NuyZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ucast/mongo2js": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/stalniy/casl/blob/master/BACKERS.md"
|
||||
}
|
||||
},
|
||||
"node_modules/@colors/colors": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.5.0.tgz",
|
||||
@@ -6169,6 +6182,41 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/core": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ucast/core/-/core-2.0.0.tgz",
|
||||
"integrity": "sha512-4XVx6LzPXZGvnZO5jp39cm/G4UvuwvEdtmg+9+4+zl6uFkCcB7UJacvtMYeBE56GJVT99Zqy6Pii7dGJq3Kz9Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@ucast/js": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@ucast/js/-/js-4.0.1.tgz",
|
||||
"integrity": "sha512-9O5xPBvwEWQk2WvO69Eh2WJB8QljVZ2vRVdFvfnKjlZwWXcYxp1lqLBhwXBU1AtuSgCvKhJPkXdjKJggUmAmQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/mongo": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ucast/mongo/-/mongo-3.0.0.tgz",
|
||||
"integrity": "sha512-kwuSH+kdB4GCR0LGhy/PEDm4PCflur89AlK82kNiYD0FvsA8A/p+0sx7m+/R8mMFAlmlkAd3VXp7sM/cLLYWYg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ucast/mongo2js": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@ucast/mongo2js/-/mongo2js-2.0.0.tgz",
|
||||
"integrity": "sha512-vNBZzRnsfLr/TSxEoxz6W6hHQ5tmWsfEeC0nCq5z8RezC1AqIRy3cfHm8AGvlGtcn+cTSFQcZremfqnz6wm+nQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ucast/core": "2.0.0",
|
||||
"@ucast/js": "4.0.1",
|
||||
"@ucast/mongo": "3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.2.tgz",
|
||||
|
||||
Reference in New Issue
Block a user