feat: add CASL authorization and AI configuration

This commit is contained in:
2026-07-11 14:25:34 +08:00
parent 8f0991a51f
commit 1e1c476bc3
59 changed files with 7733 additions and 120 deletions

View File

@@ -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 字节
# 示例 hexopenssl rand -hex 32
# 示例 base64openssl rand -base64 32
AI_CONFIG_ENCRYPTION_KEY=
# AI API Key 环境变量回退(可选)
# 若数据库未保存 Key将从该环境变量读取
# 环境变量 Key 不可从页面覆盖或清除
# AI_API_KEY=
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl仅内网部署使用
# AI_ALLOW_PRIVATE_BASE_URL=true

View File

@@ -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>

View File

@@ -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' },
],
},

View 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');
});
});
});

View 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;
}

View 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;
}

View 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;

View File

@@ -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",

View 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);
});
});

View 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 '执行失败';
}
}
}

View 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);
}
}

View 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;
}

View 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);
}
}

View 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';

View 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');
});
});
});

View 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;
}
}

View 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' }, {});
});
});
});

View 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);
}
}

View 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);
});
});
});

View 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 };
}
}

View 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;
}

View 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 {}

View 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);
});
});
});

View 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,
};
}
}

View 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);
});
});

View 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 };

View File

@@ -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 {}

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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);
});
});

View File

@@ -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)),
);
}
}

View 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 {}

View 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();
});
});
});

View 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);
}
}

View 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);
});
});

View 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 });
}
}

View 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;
}

View File

@@ -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);

View 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);
});
});

View 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);
}
}

View 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';

View 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;

View File

@@ -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);
}

View File

@@ -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')

View File

@@ -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();

View 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();
});
});

View File

@@ -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';

View 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);
});
});

View File

@@ -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;
}

View File

@@ -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<{

View File

@@ -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);

View 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 };
}
}

View 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 };

View 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);
});
});
});

View File

@@ -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' },

View File

@@ -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 {}

View File

@@ -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 });
}
}
}

View 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 SDKVercel 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. 构建可信 AgentToolContextuserId/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
View File

@@ -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",